Skip to main content

hopr_transport_session/
manager.rs

1use std::{
2    pin::Pin,
3    sync::{Arc, OnceLock, atomic::Ordering},
4    time::Duration,
5};
6
7use anyhow::anyhow;
8use futures::{Sink, SinkExt, StreamExt, TryStreamExt, future::AbortHandle};
9use futures_time::future::FutureExt as TimeExt;
10use hopr_api::types::{
11    crypto_random::Randomizable,
12    internal::{
13        prelude::HoprPseudonym,
14        routing::{DestinationRouting, RoutingOptions},
15    },
16    primitive::prelude::Address,
17};
18use hopr_crypto_packet::prelude::HoprPacket;
19use hopr_protocol_app::prelude::*;
20use hopr_protocol_start::{
21    KeepAliveFlag, KeepAliveMessage, StartChallenge, StartErrorReason, StartErrorType, StartEstablished,
22    StartInitiation,
23};
24use hopr_utils::runtime::AbortableList;
25use tracing::{debug, error, info, trace, warn};
26
27#[cfg(feature = "telemetry")]
28use crate::telemetry::{
29    SessionLifecycleState, initialize_session_metrics, remove_session_metrics_state, set_session_balancer_data,
30    set_session_state,
31};
32use crate::{
33    Capability, HoprSession, IncomingSession, SESSION_MTU, SessionClientConfig, SessionId, SessionTarget,
34    SurbBalancerConfig,
35    balancer::{
36        AtomicSurbFlowEstimator, BalancerStateValues, RateController, RateLimitSinkExt, SurbBalancer,
37        SurbControllerWithCorrection,
38        pid::{PidBalancerController, PidControllerGains},
39        simple::SimpleBalancerController,
40    },
41    errors::{SessionManagerError, TransportSessionError},
42    types::{ByteCapabilities, ClosureReason, HoprSessionConfig, HoprStartProtocol, SESSION_APPLICATION_TAG},
43    utils,
44    utils::{SurbNotificationMode, insert_into_next_slot},
45};
46
47#[cfg(all(feature = "telemetry", not(test)))]
48lazy_static::lazy_static! {
49    static ref METRIC_ACTIVE_SESSIONS: hopr_api::types::telemetry::SimpleGauge = hopr_api::types::telemetry::SimpleGauge::new(
50        "hopr_session_num_active_sessions",
51        "Number of currently active HOPR sessions"
52    ).unwrap();
53    static ref METRIC_NUM_ESTABLISHED_SESSIONS: hopr_api::types::telemetry::SimpleCounter = hopr_api::types::telemetry::SimpleCounter::new(
54        "hopr_session_established_sessions_count",
55        "Number of sessions that were successfully established as an Exit node"
56    ).unwrap();
57    static ref METRIC_NUM_INITIATED_SESSIONS: hopr_api::types::telemetry::SimpleCounter = hopr_api::types::telemetry::SimpleCounter::new(
58        "hopr_session_initiated_sessions_count",
59        "Number of sessions that were successfully initiated as an Entry node"
60    ).unwrap();
61    static ref METRIC_RECEIVED_SESSION_ERRS: hopr_api::types::telemetry::MultiCounter = hopr_api::types::telemetry::MultiCounter::new(
62        "hopr_session_received_error_count",
63        "Number of HOPR session errors received from an Exit node",
64        &["kind"]
65    ).unwrap();
66    static ref METRIC_DISPATCHED_MSGS: hopr_api::types::telemetry::MultiCounter = hopr_api::types::telemetry::MultiCounter::new(
67        "hopr_session_dispatched_messages",
68        "Number dispatched HOPR session messages and their classification",
69        &["kind"]
70    ).unwrap();
71    static ref METRIC_SENT_SESSION_ERRS: hopr_api::types::telemetry::MultiCounter = hopr_api::types::telemetry::MultiCounter::new(
72        "hopr_session_sent_error_count",
73        "Number of HOPR session errors sent to an Entry node",
74        &["kind"]
75    ).unwrap();
76}
77
78#[tracing::instrument(level = "debug", skip(session_data))]
79fn close_session(session_id: SessionId, session_data: SessionSlot, reason: ClosureReason) {
80    debug!("closing session");
81
82    #[cfg(feature = "telemetry")]
83    {
84        set_session_state(&session_id, SessionLifecycleState::Closed);
85        remove_session_metrics_state(&session_id);
86    }
87
88    if reason != ClosureReason::EmptyRead {
89        // Closing the data sender will also cause it to close from the read side
90        debug!("data tx channel closed on session");
91    }
92
93    // Terminate any additional tasks spawned by the Session
94    session_data.abort_handles.lock().abort_all();
95
96    #[cfg(all(feature = "telemetry", not(test)))]
97    METRIC_ACTIVE_SESSIONS.decrement(1.0);
98}
99
100fn initiation_timeout_max_one_way(base: Duration, hops: usize) -> Duration {
101    base * (hops as u32)
102}
103
104/// Minimum time the SURB buffer must endure if no SURBs are being produced.
105pub const MIN_SURB_BUFFER_DURATION: Duration = Duration::from_secs(1);
106/// Minimum time between SURB buffer notifications to the Entry.
107pub const MIN_SURB_BUFFER_NOTIFICATION_PERIOD: Duration = Duration::from_secs(1);
108
109/// The first challenge value used in Start protocol to initiate a session.
110pub(crate) const MIN_CHALLENGE: StartChallenge = 1;
111
112/// Maximum time to wait for counterparty to receive the target number of SURBs.
113const SESSION_READINESS_TIMEOUT: Duration = Duration::from_secs(10);
114
115/// Minimum timeout until an unfinished frame is discarded.
116const MIN_FRAME_TIMEOUT: Duration = Duration::from_millis(10);
117
118/// Timeout when sending Start protocol messages to the sink
119const EXTERNAL_SEND_TIMEOUT: Duration = Duration::from_millis(200);
120
121/// How many packets can be buffered if the HoprSession socket is not fast enough.
122#[allow(dead_code)]
123pub const SESSION_FORWARD_CAPACITY: usize = 10000;
124
125// Needs to use an UnboundedSender instead of oneshot
126// because Moka cache requires the value to be Clone, which oneshot Sender is not.
127// It also cannot be enclosed in an Arc, since calling `send` consumes the oneshot Sender.
128type SessionInitiationCache = moka::sync::Cache<
129    StartChallenge,
130    crossfire::MTx<crossfire::mpsc::One<Result<StartEstablished<SessionId>, StartErrorType>>>,
131>;
132
133/// Handles to streams and tasks spawned by the Session.
134#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, strum::Display)]
135enum SessionHandles {
136    /// Handle to the stream that facilitates ingress of data from the HOPR network into the Session.
137    Ingress,
138    /// Handle to the process that sends keep-alive messages to the Session recipient (Exit).
139    KeepAlive,
140    /// Handle to the process that monitors and balances SURBs.
141    Balancer,
142}
143
144#[derive(Clone)]
145pub(crate) struct SessionSlot {
146    // Sender does not need to be in Arc, because the receiver part is always
147    // wrapped inside DropAbortable wrapper, with abort handle added to `abort_handles`.
148    session_tx: crossfire::MTx<crossfire::mpsc::Array<ApplicationDataIn>>,
149    routing_opts: DestinationRouting,
150    // Additional tasks spawned by the Session.
151    abort_handles: Arc<parking_lot::Mutex<AbortableList<SessionHandles>>>,
152    // Allows reconfiguring of the SURB balancer on-the-fly
153    // Set on both Entry and Exit sides.
154    surb_mgmt: Arc<BalancerStateValues>,
155    // SURB flow updates happening outside of Session protocol
156    // (e.g., due to Start protocol messages).
157    surb_estimator: AtomicSurbFlowEstimator,
158}
159
160/// RAII guard that rolls back a freshly inserted [`SessionSlot`] unless the
161/// session setup is explicitly [committed](SessionSlotGuard::commit).
162///
163/// Establishing a session involves several fallible steps *after* the slot has
164/// been inserted into the Session cache (constructing the [`HoprSession`],
165/// notifying about the new session, sending the establishment message, ...).
166/// If any of these steps fails, the already inserted slot would otherwise linger
167/// in the cache until idle eviction, blocking the pseudonym (and counting towards
168/// `maximum_sessions`) in the meantime.
169///
170/// Dropping this guard without committing removes the slot and tears down the
171/// partially initialized session. Since Moka's removal is asynchronous and Rust
172/// has no asynchronous `Drop`, the cleanup is performed on a spawned task.
173struct SessionSlotGuard<'a> {
174    sessions: &'a moka::sync::Cache<SessionId, SessionSlot>,
175    active_sessions: Arc<std::sync::atomic::AtomicUsize>,
176    session_id: SessionId,
177    committed: bool,
178}
179
180impl<'a> SessionSlotGuard<'a> {
181    fn new(
182        sessions: &'a moka::sync::Cache<SessionId, SessionSlot>,
183        session_id: SessionId,
184        active_sessions: Arc<std::sync::atomic::AtomicUsize>,
185    ) -> Self {
186        Self {
187            sessions,
188            active_sessions,
189            session_id,
190            committed: false,
191        }
192    }
193
194    /// Marks the session as successfully established, preventing the slot from
195    /// being rolled back when this guard is dropped.
196    fn commit(&mut self) {
197        self.committed = true;
198
199        #[cfg(all(feature = "telemetry", not(test)))]
200        METRIC_ACTIVE_SESSIONS.increment(1.0);
201    }
202}
203
204impl Drop for SessionSlotGuard<'_> {
205    fn drop(&mut self) {
206        if !self.committed {
207            // The session setup failed after the slot was inserted: remove it so it does
208            // not block the pseudonym until idle eviction.
209            let session_id = self.session_id;
210            warn!(%session_id, "rolling back partially established session slot after setup failure");
211            if let Some(slot) = self.sessions.remove(&session_id) {
212                self.active_sessions.fetch_sub(1, Ordering::Relaxed);
213
214                close_session(session_id, slot, ClosureReason::Eviction);
215            }
216        }
217    }
218}
219
220/// Why an inbound session data packet was dropped without being delivered.
221///
222/// All three are ordinary consequences of a session tearing down or being briefly overwhelmed, not
223/// faults: a departing counterparty leaves packets in flight that arrive after its slot's sink is
224/// gone or after the slot itself is deregistered. They are reported as a [`DispatchResult::Dropped`]
225/// outcome rather than an `Err` precisely so the caller does not log them at `ERROR` — the loud
226/// teardown-race spam this distinction exists to remove.
227#[derive(Clone, Copy, Debug, PartialEq, Eq)]
228pub enum DropReason {
229    /// The slot is still registered but its data sink (receiver) has been dropped
230    /// (`TrySendError::Disconnected`). The common case when a session is being torn down.
231    SinkClosed,
232    /// The slot's inbox is full (`TrySendError::Full`): genuine backpressure, the reader is not
233    /// keeping up. Distinct from [`SinkClosed`](Self::SinkClosed) because it is transient, not a
234    /// teardown.
235    SinkFull,
236    /// No slot for this session id is registered (formerly the `UnknownData` error): the session
237    /// was fully deregistered before this packet arrived.
238    Unregistered,
239}
240
241/// Indicates the result of processing a message.
242#[derive(Clone, Debug, PartialEq, Eq)]
243pub enum DispatchResult {
244    /// Session or Start protocol message has been processed successfully.
245    Processed,
246    /// The message was not related to Start or Session protocol.
247    Unrelated(ApplicationDataIn),
248    /// The message belonged to a session but could not be delivered and was dropped. Benign — see
249    /// [`DropReason`]. Reported as `Ok` so the caller logs it quietly instead of as an `ERROR`.
250    Dropped(DropReason),
251}
252
253/// Configuration for the [`SessionManager`].
254#[derive(Clone, Debug, PartialEq, smart_default::SmartDefault)]
255pub struct SessionManagerConfig {
256    /// The maximum chunk of data that can be written to the Session's input buffer.
257    ///
258    /// Default is 1500.
259    #[default(1500)]
260    pub frame_mtu: usize,
261
262    /// The maximum time for an incomplete frame to stay in the Session's output buffer.
263    ///
264    /// Must exceed the worst-case SURB replenishment latency on the return path.
265    /// The SURB balancer replenishes via KeepAlive every ~2 s; the retry loop in
266    /// the routing resolver sleeps 5 ms per attempt and can block for the full
267    /// replenishment cycle.  Setting this below the KeepAlive period causes the
268    /// sequencer to discard frames that arrive after a SURB-starved peer finally
269    /// gets new SURBs and sends its echo.
270    ///
271    /// Default is 3 s.
272    #[default(Duration::from_secs(3))]
273    pub max_frame_timeout: Duration,
274
275    /// Maximum number of segments to buffer in the downstream transport of a Session's socket.
276    /// If 0 is given, the transport is unbuffered.
277    ///
278    /// Default is 0.
279    #[default(0)]
280    pub max_buffered_segments: usize,
281
282    /// Abandon the frame due next once this many later frames are waiting behind it, rather than
283    /// holding them for the whole of [`Self::max_frame_timeout`].
284    ///
285    /// The two answer different questions. `max_frame_timeout` is how long a *missing* frame is
286    /// waited for, and is set here to exceed the ~2 s SURB KeepAlive so a starved peer's late echo
287    /// is not discarded. This bounds how much *already received* data is held hostage during that
288    /// wait -- a cost paid once per gap, which compounds as later frames queue up.
289    ///
290    /// Without it, a session that cannot retransmit waits the full timeout for a frame that will
291    /// never arrive. Measured on a 5-node cluster after killing a return relayer: 98.5 % of bytes
292    /// returned over the wire, 0.60 % reached the application, and the application-side
293    /// inter-arrival median sat exactly on the 3 s timeout.
294    ///
295    /// The right value tracks reordering depth -- throughput x latency spread / frame size -- so
296    /// it is deployment-specific. Tune with `SessionConfig::max_frames_behind_gap`; too low converts
297    /// ordinary reordering into loss, too high leaves the stall in place.
298    ///
299    /// Default is 256, which is roughly 3-4x the reordering depth of the cluster it was measured
300    /// on (~0.5 MB/s over paths spread across 10-260 ms, 1500 B frames, so ~70-85 frames in
301    /// flight out of order). The margin is not cosmetic: at 64 -- about one reordering depth --
302    /// a *healthy* baseline dropped from 100 % to 92.2 % arrival, because ordinary reordering was
303    /// being read as loss. At 256 the same baseline returned 100 % while a return relayer killed
304    /// mid-session still recovered 97.1 %, against 0.60 % with the bound absent.
305    ///
306    /// Default is 256.
307    #[default(Some(256))]
308    pub max_frames_behind_gap: Option<usize>,
309
310    /// The base timeout for initiation of Session initiation.
311    ///
312    /// The actual timeout is adjusted according to the number of hops for that Session:
313    /// `t = initiation_time_out_base * (num_forward_hops + num_return_hops + 2)`
314    ///
315    /// Default is 500 milliseconds.
316    #[default(Duration::from_millis(500))]
317    pub initiation_timeout_base: Duration,
318
319    /// Timeout for Session to be closed due to inactivity.
320    ///
321    /// Default is 180 seconds.
322    #[default(Duration::from_secs(180))]
323    pub idle_timeout: Duration,
324
325    /// Minimum interval at which an establishing Session's cache slot is refreshed
326    /// ("touched") to keep [`idle_timeout`](Self::idle_timeout) from evicting it while
327    /// SURBs pre-load.
328    ///
329    /// The touch period is [`idle_timeout`](Self::idle_timeout)` / 2`, floored to this
330    /// value so it never drops below a sane minimum for very short idle timeouts.
331    ///
332    /// Default is 100 milliseconds.
333    #[default(Duration::from_millis(100))]
334    pub min_session_touch_period: Duration,
335
336    /// The sampling interval for SURB balancer.
337    /// It will make SURB control decisions regularly at this interval.
338    ///
339    /// Default is 100 milliseconds.
340    #[default(Duration::from_millis(100))]
341    pub balancer_sampling_interval: Duration,
342
343    /// Initial packets per second egress rate on an incoming Session.
344    ///
345    /// This only applies to incoming Sessions without the [`Capability::NoRateControl`] flag set.
346    ///
347    /// Default is 10 packets/second.
348    #[default(10)]
349    pub initial_return_session_egress_rate: usize,
350
351    /// Minimum period of time for which a SURB buffer at the Exit must
352    /// endure if no SURBs are being received.
353    ///
354    /// In other words, it is the minimum period of time an Exit must withstand when
355    /// no SURBs are received from the Entry at all. To do so, the egress traffic
356    /// will be shaped accordingly to meet this requirement.
357    ///
358    /// This only applies to incoming Sessions without the [`Capability::NoRateControl`] flag set.
359    ///
360    /// Default is 5 seconds, minimum is 1 second.
361    #[default(Duration::from_secs(5))]
362    pub minimum_surb_buffer_duration: Duration,
363
364    /// Indicates the maximum number of SURBs in the SURB buffer to be requested when creating a new Session.
365    ///
366    /// This value is theoretically capped by the size of the global transport SURB ring buffer,
367    /// so values greater than that do not make sense. This value should be ideally set equal
368    /// to the size of the global transport SURB RB.
369    ///
370    /// Default is 10 000 SURBs.
371    #[default(10_000)]
372    pub maximum_surb_buffer_size: usize,
373
374    /// If set, the Session recipient (Exit) will notify the Session initiator (Entry) about
375    /// its SURB balance for the Session using keep-alive packets periodically.
376    ///
377    /// Keep in mind that each notification also costs 1 SURB, so the notification period should
378    /// not be too frequent.
379    ///
380    /// These notifications are the only absolute correction of the Entry's dead-reckoned
381    /// estimate of the Exit's SURB buffer. Without them, every packet lost in either
382    /// direction permanently inflates the Entry's estimate, until the Exit silently runs
383    /// out of SURBs and can no longer send any reply data.
384    ///
385    /// Default is 60 seconds (None disables the notifications), minimum is 1 second.
386    #[default(Some(Duration::from_secs(60)))]
387    pub surb_balance_notify_period: Option<Duration>,
388
389    /// If set, the Session initiator (Entry) will notify the Session recipient (Exit) about
390    /// the local SURB balancer target using keep-alive packets from the SURB balancer.
391    ///
392    /// This is useful when the client plans to change the SURB balancer target dynamically.
393    ///
394    /// Default is true.
395    #[default(true)]
396    pub surb_target_notify: bool,
397
398    /// Maximum number of concurrent sessions allowed.
399    ///
400    /// Default is 10_000.
401    #[default(10_000)]
402    pub maximum_sessions: usize,
403
404    /// How many packets can be buffered if the [`HoprSession`] input socket is not fast enough.
405    ///
406    /// Controls the capacity of the internal `crossfire` channel used for each session slot.
407    ///
408    /// Default is 10_000.
409    #[default(10000)]
410    pub session_forward_capacity: usize,
411}
412
413// Type-erased sink used by the `SessionManager` to notify about newly incoming sessions.
414// The errors produced by the underlying sink are remapped into `SessionManagerError`.
415type IncomingSessionSink = Pin<Box<dyn Sink<IncomingSession, Error = SessionManagerError> + Send>>;
416
417type SessionNotifiers = (
418    Arc<hopr_utils::runtime::prelude::Mutex<IncomingSessionSink>>,
419    crossfire::MTx<crossfire::mpsc::Array<(SessionId, ClosureReason)>>,
420);
421
422// Sink for processing Start protocol messages.
423// Must be within Arc to be shared across SessionManager clones.
424// The inner OnceLock is set once in `start()` and read in `dispatch_message`.
425type StartProtocolMsgSink = Arc<OnceLock<crossfire::MTx<crossfire::mpsc::Array<(HoprPseudonym, HoprStartProtocol)>>>>;
426
427/// Manages lifecycles of Sessions.
428///
429/// Once the manager is [started](SessionManager::start), the [`SessionManager::dispatch_message`]
430/// should be called for each [`ApplicationData`] received by the node.
431/// This way, the `SessionManager` takes care of proper Start sub-protocol message processing
432/// and correct dispatch of Session-related packets to individual existing Sessions.
433///
434/// Secondly, the manager can initiate new outgoing sessions via [`SessionManager::new_session`],
435/// probe sessions using [`SessionManager::ping_session`]
436/// and list them via [`SessionManager::active_sessions`].
437///
438/// Since the `SessionManager` operates over the HOPR protocol,
439/// the message transport `S` is required.
440/// Such transport must also be `Clone`, since it will be cloned into all the created [`HoprSession`] objects.
441///
442/// ## SURB balancing
443///
444/// The manager also can take care of automatic [SURB balancing](SurbBalancerConfig) per Session.
445///
446/// With each packet sent from the session initiator over to the receiving party, zero to 2 SURBs might be delivered.
447/// When the receiving party wants to send reply packets back, it must consume 1 SURB per packet. This
448/// means that if the difference between the SURBs delivered and SURBs consumed is negative, the receiving party
449/// might soon run out of SURBs. If SURBs run out, the reply packets will be dropped, causing likely quality of
450/// service degradation.
451///
452/// In an attempt to counter this effect, there are two co-existing automated modes of SURB balancing:
453/// *local SURB balancing* and *remote SURB balancing*.
454///
455/// ### Local SURB balancing
456///
457/// Local SURB balancing is performed on the sessions that were initiated by another party (and are
458/// therefore incoming to us).
459/// The local SURB balancing mechanism continuously evaluates the rate of SURB consumption and retrieval,
460/// and if SURBs are running out, the packet egress shaping takes effect. This by itself does not
461/// avoid the depletion of SURBs but slows it down in the hope that the initiating party can deliver
462/// more SURBs over time. This might happen either organically by sending effective payloads that
463/// allow non-zero number of SURBs in the packet, or non-organically by delivering KeepAlive messages
464/// via *remote SURB balancing*.
465///
466/// The egress shaping is done automatically, unless the Session initiator sets the [`Capability::NoRateControl`]
467/// flag during Session initiation.
468///
469/// ### Remote SURB balancing
470///
471/// Remote SURB balancing is performed by the Session initiator. The SURB balancer estimates the number of SURBs
472/// delivered to the other party, and also the number of SURBs consumed by seeing the amount of traffic received
473/// in replies.
474/// When enabled, a desired target level of SURBs at the Session counterparty is set. According to measured
475/// inflow and outflow of SURBs to/from the counterparty, the production of non-organic SURBs is started
476/// via keep-alive messages (sent to counterparty) and is controlled to maintain that target level.
477///
478/// In other words, the Session initiator tries to compensate for the usage of SURBs by the counterparty by
479/// sending new ones via the keep-alive messages.
480///
481/// This mechanism is configurable via the `surb_management` field in [`SessionClientConfig`].
482///
483/// ### Possible scenarios
484///
485/// There are 4 different scenarios of local vs. remote SURB balancing configuration, but
486/// an equilibrium (= matching the SURB production and consumption) is most likely to be reached
487/// only when both are configured (the ideal case below):
488///
489/// #### 1. Ideal local and remote SURB balancing
490///
491/// 1. The Session recipient (Exit) set the `initial_return_session_egress_rate`, `max_surb_buffer_duration` and
492///    `maximum_surb_buffer_size` values in the [`SessionManagerConfig`].
493/// 2. The Session initiator (Entry) sets the [`target_surb_buffer_size`](SurbBalancerConfig) which matches the
494///    [`maximum_surb_buffer_size`](SessionManagerConfig) of the counterparty.
495/// 3. The Session initiator (Entry) does *NOT* set the [`Capability::NoRateControl`] capability flag when opening
496///    Session.
497/// 4. The Session initiator (Entry) sets [`max_surbs_per_sec`](SurbBalancerConfig) slightly higher than the
498///    `maximum_surb_buffer_size / max_surb_buffer_duration` value configured at the counterparty.
499///
500/// In this situation, the maximum Session egress from Exit to the Entry is given by the
501/// `maximum_surb_buffer_size / max_surb_buffer_duration` ratio. If there is enough bandwidth,
502/// the (remote) SURB balancer sending SURBs to the Exit will stabilize roughly at this rate of SURBs/sec,
503/// and the whole system will be in equilibrium during the Session's lifetime (under ideal network conditions).
504///
505/// #### 2. Remote SURB balancing only
506///
507/// 1. The Session initiator (Entry) *DOES* set the [`Capability::NoRateControl`] capability flag when opening Session.
508/// 2. The Session initiator (Entry) sets `max_surbs_per_sec` and `target_surb_buffer_size` values in
509///    [`SurbBalancerConfig`]
510///
511/// In this one-sided situation, the Entry node floods the Exit node with SURBs,
512/// only based on its estimated consumption of SURBs at the Exit. The Exit's egress is not
513/// rate-limited at all. If the Exit runs out of SURBs at any point in time, it will simply drop egress packets.
514///
515/// This configuration could potentially only lead to an equilibrium
516/// when the `SurbBalancer` at the Entry can react fast enough to Exit's demand.
517///
518/// #### 3. Local SURB balancing only
519///
520/// 1. The Session recipient (Exit) set the `initial_return_session_egress_rate`, `max_surb_buffer_duration` and
521///    `maximum_surb_buffer_size` values in the [`SessionManagerConfig`].
522/// 2. The Session initiator (Entry) does *NOT* set the [`Capability::NoRateControl`] capability flag when opening
523///    Session.
524/// 3. The Session initiator (Entry) does *NOT* set the [`SurbBalancerConfig`] at all when opening Session.
525///
526/// In this one-sided situation, the Entry node does not provide any additional SURBs at all (except the
527/// ones that are naturally carried by the egress packets which have space to hold SURBs). It relies
528/// only on the Session egress limiting of the Exit node.
529/// The Exit will limit the egress roughly to the rate of natural SURB occurrence in the ingress.
530///
531/// This configuration could potentially only lead to an equilibrium when uploading non-full packets
532/// (ones that can carry at least a single SURB), and the Exit's egress is limiting itself to such a rate.
533/// If Exit's egress reaches low values due to SURB scarcity, the upper layer protocols over Session might break.
534///
535/// #### 4. No SURB balancing on each side
536///
537/// 1. The Session initiator (Entry) *DOES* set the [`Capability::NoRateControl`] capability flag when opening Session.
538/// 2. The Session initiator (Entry) does *NOT* set the [`SurbBalancerConfig`] at all when opening Session.
539///
540/// In this situation, no additional SURBs are being produced by the Entry and no Session egress rate-limiting
541/// takes place at the Exit.
542///
543/// This configuration can only lead to an equilibrium when Entry sends non-full packets (ones that carry
544/// at least a single SURB) and the Exit is consuming the SURBs (Session egress) at a slower or equal rate.
545/// Such configuration is very fragile, as any disturbances in the SURB flow might lead to a packet drop
546/// at the Exit's egress.
547///
548/// ### SURB decay
549///
550/// In a hypothetical scenario of a non-zero packet loss, the Session initiator (Entry) might send a
551/// certain number of SURBs to the Session recipient (Exit), but only a portion of it is actually delivered.
552/// The Entry has no way of knowing that and assumes that everything has been delivered.
553/// A similar problem happens when the Exit uses SURBs to construct return packets, but only a portion
554/// of those packets is actually delivered to the Entry. At this point, the Entry also subtracts
555/// fewer SURBs from its SURB estimate at the Exit.
556///
557/// In both situations, the Entry thinks there are more SURBs available at the Exit than there really are.
558///
559/// To compensate for a potential packet loss, the Entry's estimation of Exit's SURB buffer is regularly
560/// diminished by a percentage of the `target_surb_buffer_size`, even if no incoming traffic from the
561/// Exit is detected.
562///
563/// This behavior can be controlled via the `surb_decay` field of [`SurbBalancerConfig`].
564///
565/// ### SURB balance and target notification
566///
567/// The Session recipient (Exit) can notify the Session initiator (Entry) periodically about its estimated
568/// number of SURBs for the Session. This can help the Entry to adjust its approximation of that level so
569/// that its Local SURB balancer can better intervene.
570/// This can be set using the `surb_balance_notify_period` field of [`SessionManagerConfig`] for the Exit.
571///
572/// Likewise, the Entry can inform the Exit about its desired SURB buffer target so that the Exit
573/// can better accommodate its Remote SURB balancing.
574/// This can be set using the `surb_target_notify` field of the [`SessionManagerConfig`] of each new Session.
575///
576/// Both mechanisms leverage the Keep Alive message to report the respective values.
577pub struct SessionManager<S> {
578    session_initiations: SessionInitiationCache,
579    session_notifiers: Arc<OnceLock<SessionNotifiers>>,
580    start_protocol_tx: StartProtocolMsgSink,
581    /// Authoritative session count for admission control.
582    /// Incremented atomically inside `allocate_session_slot` before the cache insertion,
583    /// and decremented at every removal path (explicit close, eviction, guard rollback).
584    active_sessions: Arc<std::sync::atomic::AtomicUsize>,
585    sessions: moka::sync::Cache<SessionId, SessionSlot>,
586    msg_sender: Arc<OnceLock<S>>,
587    cfg: SessionManagerConfig,
588}
589
590impl<S> Clone for SessionManager<S> {
591    fn clone(&self) -> Self {
592        Self {
593            session_initiations: self.session_initiations.clone(),
594            session_notifiers: self.session_notifiers.clone(),
595            start_protocol_tx: self.start_protocol_tx.clone(),
596            active_sessions: self.active_sessions.clone(),
597            sessions: self.sessions.clone(),
598            cfg: self.cfg.clone(),
599            msg_sender: self.msg_sender.clone(),
600        }
601    }
602}
603
604fn session_config(cfg: &SessionManagerConfig, capabilities: crate::Capabilities) -> HoprSessionConfig {
605    session_config_with(cfg, capabilities, None)
606}
607
608/// As [`session_config`], with the initiating session's own head-of-line bound.
609///
610/// `None` inherits the node default; `Some(0)` disables the bound for this session; `Some(n)` sets
611/// it. The zero-disables convention matches the node setting, so the per-session
612/// and node-wide knobs cannot mean opposite things by the same value.
613///
614/// Sessions accepted from a peer pass `None`: the bound governs how *we* reassemble what arrives,
615/// so it is ours to choose, not the initiator's to impose on us.
616fn session_config_with(
617    cfg: &SessionManagerConfig,
618    capabilities: crate::Capabilities,
619    max_frames_behind_gap: Option<usize>,
620) -> HoprSessionConfig {
621    // Only a session that cannot recover the missing frame should abandon it early. With
622    // retransmission the gap is a request away, so waiting is productive and cutting it short
623    // would discard data that was on its way back; without it the wait is for something that is
624    // never coming, and every frame behind the gap is held for nothing.
625    let can_retransmit =
626        capabilities.contains(Capability::RetransmissionAck) || capabilities.contains(Capability::RetransmissionNack);
627
628    // The session's own value when it stated one, the node's otherwise. `Some(0)` disables the
629    // bound at either level -- a threshold of zero would abandon every gap before a single frame
630    // arrived behind it, which nobody wants and which the sequencer would silently clamp to one.
631    let bound = match max_frames_behind_gap.or(cfg.max_frames_behind_gap) {
632        Some(0) | None => None,
633        Some(n) => Some(n),
634    };
635
636    HoprSessionConfig {
637        capabilities,
638        frame_mtu: cfg.frame_mtu,
639        frame_timeout: cfg.max_frame_timeout,
640        max_buffered_segments: cfg.max_buffered_segments,
641        max_frames_behind_gap: (!can_retransmit).then_some(bound).flatten(),
642    }
643}
644
645#[cfg(feature = "telemetry")]
646fn initialize_session_telemetry(
647    session_id: SessionId,
648    cfg: &SessionManagerConfig,
649    capabilities: crate::Capabilities,
650    surb_estimator: Option<&AtomicSurbFlowEstimator>,
651    surb_mgmt: Option<&Arc<BalancerStateValues>>,
652) {
653    initialize_session_metrics(session_id, session_config(cfg, capabilities));
654    set_session_state(&session_id, SessionLifecycleState::Active);
655    if let (Some(estimator), Some(mgmt)) = (surb_estimator, surb_mgmt) {
656        set_session_balancer_data(&session_id, estimator.clone(), mgmt.clone());
657    }
658}
659
660async fn send_via_msg_sender<S, D>(
661    msg_sender: &mut S,
662    routing: DestinationRouting,
663    data: D,
664    error_context: &'static str,
665) -> crate::errors::Result<()>
666where
667    S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Unpin,
668    S::Error: std::error::Error + Send + Sync + Clone + 'static,
669    D: TryInto<ApplicationData>,
670    D::Error: std::error::Error + Send + Sync + 'static,
671{
672    let app_data: ApplicationData = data.try_into().map_err(SessionManagerError::other)?;
673    msg_sender
674        .send((routing, ApplicationDataOut::with_no_packet_info(app_data)))
675        .timeout(futures_time::time::Duration::from(EXTERNAL_SEND_TIMEOUT))
676        .await
677        .map_err(|_| {
678            error!("timeout sending {error_context}");
679            TransportSessionError::Timeout
680        })?
681        .map_err(|error| {
682            error!(%error, "failed to send {error_context}");
683            SessionManagerError::other(error)
684        })?;
685    Ok(())
686}
687
688impl<S> SessionManager<S>
689where
690    S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Clone + Send + Sync + Unpin + 'static,
691    S::Error: std::error::Error + Send + Sync + Clone + 'static,
692{
693    /// Creates a new instance given the [`config`](SessionManagerConfig).
694    pub fn new(mut cfg: SessionManagerConfig) -> Self {
695        let maximum_sessions = cfg.maximum_sessions;
696        cfg.surb_balance_notify_period = cfg
697            .surb_balance_notify_period
698            .map(|p| p.max(MIN_SURB_BUFFER_NOTIFICATION_PERIOD));
699        cfg.minimum_surb_buffer_duration = cfg.minimum_surb_buffer_duration.max(MIN_SURB_BUFFER_DURATION);
700
701        // Ensure the Frame MTU is at least the size of the Session segment MTU payload
702        cfg.frame_mtu = cfg.frame_mtu.max(SESSION_MTU);
703        cfg.max_frame_timeout = cfg.max_frame_timeout.max(MIN_FRAME_TIMEOUT);
704
705        #[cfg(all(feature = "telemetry", not(test)))]
706        METRIC_ACTIVE_SESSIONS.set(0.0);
707
708        let active_sessions: Arc<std::sync::atomic::AtomicUsize> = Arc::new(std::sync::atomic::AtomicUsize::new(0));
709        let active_sessions_for_listener = active_sessions.clone();
710
711        let msg_sender = Arc::new(OnceLock::new());
712        Self {
713            msg_sender: msg_sender.clone(),
714            session_initiations: moka::sync::Cache::builder()
715                .max_capacity(maximum_sessions as u64)
716                .time_to_live(
717                    2 * initiation_timeout_max_one_way(
718                        cfg.initiation_timeout_base,
719                        RoutingOptions::MAX_INTERMEDIATE_HOPS,
720                    ),
721                )
722                .build(),
723            sessions: moka::sync::Cache::builder()
724                .max_capacity(maximum_sessions as u64)
725                .time_to_idle(cfg.idle_timeout)
726                .eviction_listener(move |session_id: Arc<SessionId>, entry, reason| match &reason {
727                    moka::notification::RemovalCause::Expired | moka::notification::RemovalCause::Size => {
728                        trace!(?session_id, ?reason, "session evicted from the cache");
729                        active_sessions_for_listener.fetch_sub(1, Ordering::Relaxed);
730                        close_session(*session_id.as_ref(), entry, ClosureReason::Eviction);
731                    }
732                    _ => {}
733                })
734                .build(),
735            session_notifiers: Arc::new(OnceLock::new()),
736            start_protocol_tx: Arc::new(OnceLock::new()),
737            active_sessions,
738            cfg,
739        }
740    }
741
742    /// Starts the instance with the given `msg_sender` `Sink`
743    /// and a channel `new_session_notifier` used to notify when a new incoming session is opened to us.
744    ///
745    /// This method must be called prior to any calls to [`SessionManager::new_session`] or
746    /// [`SessionManager::dispatch_message`].
747    pub fn start<T>(&self, msg_sender: S, new_session_notifier: T) -> crate::errors::Result<Vec<AbortHandle>>
748    where
749        T: futures::Sink<IncomingSession> + Send + 'static,
750        T::Error: std::error::Error + Send + Sync + 'static,
751    {
752        self.msg_sender
753            .set(msg_sender)
754            .map_err(|_| SessionManagerError::AlreadyStarted)?;
755
756        // Re-map the user-provided sink errors to `SessionManagerError` and erase the concrete
757        // type, so that the `SessionManager` does not need to be generic over it. This also avoids
758        // having to spawn a separate task to forward items between channels: senders simply lock
759        // the sink and send directly.
760        let new_session_notifier: IncomingSessionSink =
761            Box::pin(new_session_notifier.sink_map_err(SessionManagerError::other));
762        let new_session_notifier = Arc::new(hopr_utils::runtime::prelude::Mutex::new(new_session_notifier));
763
764        let (session_close_tx, session_close_rx) =
765            crossfire::mpsc::bounded_blocking_async(self.cfg.maximum_sessions + 10);
766        self.session_notifiers
767            .set((new_session_notifier, session_close_tx))
768            .map_err(|_| SessionManagerError::AlreadyStarted)?;
769
770        let (start_protocol_tx, start_protocol_rx) =
771            crossfire::mpsc::bounded_blocking_async(self.cfg.maximum_sessions + 10);
772        let _ = self.start_protocol_tx.set(start_protocol_tx);
773
774        let myself = self.clone();
775        let closure_diag = hopr_utils::runtime::diagnostics::ConcurrentDiagnostics::new(
776            "session_close_for_each_concurrent",
777            module_path!(),
778            file!(),
779            line!(),
780        );
781        let ah_closure_notifications = hopr_utils::spawn_as_abortable_named!(
782            "session_close_notifications",
783            session_close_rx.into_stream().for_each_concurrent(
784                self.cfg.maximum_sessions + 10,
785                move |(session_id, closure_reason)| {
786                    let myself = myself.clone();
787                    let closure_diag = closure_diag.clone();
788                    closure_diag.wrap(|| {
789                        // These notifications come from the Sessions themselves once
790                        // an empty read is encountered, which means the closure was done by the
791                        // other party.
792                        if let Some(session_data) = myself.sessions.remove(&session_id) {
793                            myself.active_sessions.fetch_sub(1, Ordering::Relaxed);
794                            close_session(session_id, session_data, closure_reason);
795                        } else {
796                            // Do not treat this as an error
797                            debug!(
798                                ?session_id,
799                                ?closure_reason,
800                                "could not find session id to close, maybe the session is already closed"
801                            );
802                        }
803                        futures::future::ready(())
804                    })
805                }
806            )
807        );
808
809        // This is necessary to evict expired entries from the caches if
810        // no session-related operations happen at all.
811        // This ensures the dangling expired sessions are properly closed
812        // and their closure is timely notified to the other party.
813        let myself = self.clone();
814        let ah_session_expiration = hopr_utils::spawn_as_abortable!(async move {
815            let jitter = hopr_api::types::crypto_random::random_float_in_range(1.0..1.5);
816            let timeout = 2 * initiation_timeout_max_one_way(
817                myself.cfg.initiation_timeout_base,
818                RoutingOptions::MAX_INTERMEDIATE_HOPS,
819            )
820            .min(myself.cfg.idle_timeout)
821            .mul_f64(jitter)
822                / 2;
823            futures_time::stream::interval(timeout.into())
824                .for_each(|_| async {
825                    trace!("executing session cache evictions");
826                    myself.sessions.run_pending_tasks();
827                    myself.session_initiations.run_pending_tasks();
828                })
829                .await;
830        });
831
832        // Begin processing of Start protocol messages
833        let myself = self.clone();
834        let ah_start_protocol = hopr_utils::spawn_as_abortable_named!(
835            "session_start_protocol_processor",
836            start_protocol_rx.into_stream().for_each_concurrent(
837                Some(self.cfg.maximum_sessions + 10),
838                move |(pseudonym, protocol_msg)| {
839                    let myself = myself.clone();
840                    async move {
841                        let result = match protocol_msg {
842                            HoprStartProtocol::StartSession(session_req) => {
843                                myself.handle_incoming_session_initiation(pseudonym, session_req).await
844                            }
845                            HoprStartProtocol::SessionEstablished(est) => myself.handle_session_established(est).await,
846                            HoprStartProtocol::SessionError(error_type) => {
847                                myself.handle_session_error(error_type).await
848                            }
849                            HoprStartProtocol::KeepAlive(msg) => myself.handle_keep_alive(msg).await,
850                        };
851
852                        if let Err(error) = result {
853                            error!(%error, "failed to process Start protocol message");
854                        }
855                    }
856                }
857            )
858        );
859
860        Ok(vec![ah_closure_notifications, ah_session_expiration, ah_start_protocol])
861    }
862
863    /// Check if [`start`](SessionManager::start) has been called and the instance is running.
864    pub fn is_started(&self) -> bool {
865        self.session_notifiers.get().is_some()
866    }
867
868    /// Atomically allocates a new [`SessionSlot`] for `session_id` and returns an RAII
869    /// [`SessionSlotGuard`] for it.
870    ///
871    /// Establishing a session involves several fallible steps *after* the slot has been
872    /// inserted. The returned guard rolls the slot back - tearing the partially
873    /// established session down via [`close_session`] - unless it is
874    /// [committed](SessionSlotGuard::commit).
875    ///
876    /// The active-sessions gauge is incremented here, atomically with the insertion and
877    /// the guard creation, precisely so that it is always paired with the guard's
878    /// rollback decrement (performed through [`close_session`]). This keeps the gauge
879    /// accurate: it is never decremented for a slot that was not counted in the first
880    /// place, and every counted slot is decremented exactly once when it leaves the cache.
881    ///
882    /// Returns `None` if a slot for `session_id` already exists; in that case nothing is
883    /// inserted, the gauge is left untouched, and no guard is produced. The atomic `entry`
884    /// API guarantees that only one concurrent caller can claim the slot for a given
885    /// pseudonym (avoiding a TOCTOU race), which also rules out loopback sessions onto
886    /// ourselves.
887    ///
888    /// Capacity is enforced by an atomic counter incremented *before* the cache insertion,
889    /// making it impossible for two concurrent callers (with different session IDs) to both
890    /// succeed when the cache is already at `maximum_sessions`.
891    fn allocate_session_slot(&self, session_id: SessionId, slot: SessionSlot) -> Option<SessionSlotGuard<'_>> {
892        // Try to claim a session slot before touching the cache. `fetch_update` atomically
893        // increments only if the value is strictly below the limit, preventing two concurrent
894        // callers from both succeeding when already at capacity.
895        let counter = &self.active_sessions;
896        #[allow(clippy::incompatible_msrv)]
897        let did_reserve = counter
898            .try_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
899                (n < self.cfg.maximum_sessions).then_some(n + 1)
900            })
901            .is_ok();
902
903        if !did_reserve {
904            return None;
905        }
906
907        let result =
908            self.sessions
909                .entry(session_id)
910                .and_compute_with(|entry: Option<moka::Entry<SessionId, SessionSlot>>| {
911                    if entry.is_none() {
912                        moka::ops::compute::Op::Put(slot)
913                    } else {
914                        // Duplicate key — release the reservation so the counter stays accurate.
915                        counter.fetch_sub(1, Ordering::Relaxed);
916                        moka::ops::compute::Op::Nop
917                    }
918                });
919
920        match result {
921            moka::ops::compute::CompResult::Inserted(_) => {
922                // take_guard borrows self, so the guard stores the counter clone separately.
923                Some(SessionSlotGuard::new(&self.sessions, session_id, counter.clone()))
924            }
925            _ => None,
926        }
927    }
928
929    /// Initiates a new outgoing Session to `destination` with the given configuration.
930    ///
931    /// If the Session's counterparty does not respond within
932    /// the [configured](SessionManagerConfig) period,
933    /// this method returns [`TransportSessionError::Timeout`].
934    ///
935    /// It will also fail if the instance has not been [started](SessionManager::start).
936    pub async fn new_session(
937        &self,
938        destination: Address,
939        target: SessionTarget,
940        cfg: SessionClientConfig,
941    ) -> crate::errors::Result<HoprSession> {
942        self.sessions.run_pending_tasks();
943        if self.cfg.maximum_sessions <= self.active_sessions.load(Ordering::Relaxed) {
944            return Err(SessionManagerError::TooManySessions.into());
945        }
946
947        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
948
949        let (tx_initiation_done, rx_initiation_done): (
950            crossfire::MTx<crossfire::mpsc::One<_>>,
951            crossfire::AsyncRx<crossfire::mpsc::One<_>>,
952        ) = crossfire::mpsc::build(crossfire::mpsc::One::new());
953
954        let (challenge, _) = insert_into_next_slot(
955            &self.session_initiations,
956            |ch| {
957                if let Some(challenge) = ch {
958                    ((challenge + 1) % hopr_api::types::crypto_random::MAX_RANDOM_INTEGER).max(MIN_CHALLENGE)
959                } else {
960                    hopr_api::types::crypto_random::random_integer(MIN_CHALLENGE, None)
961                }
962            },
963            |_| tx_initiation_done,
964            Some(self.cfg.maximum_sessions as u64),
965        )
966        .ok_or(SessionManagerError::NoChallengeSlots)?; // almost impossible with u64
967
968        // Prepare the session initiation message in the Start protocol
969        trace!(challenge, ?cfg, "initiating session with config");
970        let start_session_msg = HoprStartProtocol::StartSession(StartInitiation {
971            challenge,
972            target,
973            capabilities: ByteCapabilities(cfg.capabilities),
974            additional_data: if !cfg.capabilities.contains(Capability::NoRateControl) {
975                cfg.surb_management
976                    .map(|c| c.target_surb_buffer_size)
977                    .unwrap_or(
978                        self.cfg.initial_return_session_egress_rate as u64
979                            * self
980                                .cfg
981                                .minimum_surb_buffer_duration
982                                .max(MIN_SURB_BUFFER_DURATION)
983                                .as_secs(),
984                    )
985                    .min(u32::MAX as u64) as u32
986            } else {
987                0
988            },
989        });
990
991        let pseudonym = cfg.pseudonym.unwrap_or(HoprPseudonym::random());
992        let forward_routing = DestinationRouting::Forward {
993            destination: Box::new(destination.into()),
994            pseudonym: Some(pseudonym), // Session must use a fixed pseudonym already
995            forward_options: cfg.forward_path_options.clone(),
996            return_options: cfg.return_path_options.clone().into(),
997        };
998
999        // Send the Session initiation message
1000        info!(challenge, %pseudonym, %destination, "new session request");
1001        send_via_msg_sender(
1002            &mut msg_sender,
1003            forward_routing.clone(),
1004            start_session_msg,
1005            "session request message",
1006        )
1007        .await
1008        .map_err(|error| {
1009            self.session_initiations.remove(&challenge);
1010            TransportSessionError::packet_sending(error)
1011        })?;
1012
1013        // The timeout is given by the number of hops requested
1014        let initiation_timeout: futures_time::time::Duration = initiation_timeout_max_one_way(
1015            self.cfg.initiation_timeout_base,
1016            cfg.forward_path_options.count_hops() + cfg.return_path_options.count_hops() + 2,
1017        )
1018        .into();
1019
1020        // Await session establishment response from the Exit node or timeout
1021
1022        trace!(challenge, "awaiting session establishment");
1023        match rx_initiation_done
1024            .into_stream()
1025            .try_next()
1026            .timeout(initiation_timeout)
1027            .await
1028        {
1029            Ok(Ok(Some(est))) => {
1030                // Session has been established, construct it
1031                let session_id = est.session_id;
1032                debug!(challenge = est.orig_challenge, ?session_id, "started a new session");
1033
1034                let (session_tx, session_rx) =
1035                    crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
1036                let (session_rx, session_rx_ah) = hopr_utils::runtime::DropAbortable::new(session_rx.into_stream());
1037
1038                let mut abort_handles = AbortableList::default();
1039                abort_handles.insert(SessionHandles::Ingress, session_rx_ah);
1040
1041                let notifier = self
1042                    .session_notifiers
1043                    .get()
1044                    .map(|(_, notifier)| {
1045                        let notifier = notifier.clone();
1046                        Box::new(move |session_id: SessionId, reason: ClosureReason| {
1047                            let _ = notifier
1048                                .try_send((session_id, reason))
1049                                .inspect_err(|error| error!(%session_id, %error, "failed to notify session closure"));
1050                        })
1051                    })
1052                    .ok_or(SessionManagerError::NotStarted)?;
1053
1054                // NOTE: the Exit node can have different `max_surb_buffer_size`
1055                // setting on the Session manager, so it does not make sense to cap it here
1056                // with our maximum value.
1057                if let Some(balancer_config) = cfg.surb_management {
1058                    let surb_estimator = AtomicSurbFlowEstimator::default();
1059
1060                    // Sender responsible for keep-alive and Session data will be counting produced SURBs
1061                    let surb_estimator_clone = surb_estimator.clone();
1062                    let full_surb_scoring_sender =
1063                        msg_sender.with(move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
1064                            let produced = data.estimate_surbs_with_msg() as u64;
1065                            // Count how many SURBs we sent with each packet
1066                            surb_estimator_clone
1067                                .produced
1068                                .fetch_add(produced, std::sync::atomic::Ordering::Relaxed);
1069                            #[cfg(feature = "telemetry")]
1070                            crate::telemetry::record_session_surb_produced(&session_id, produced);
1071                            futures::future::ok::<_, S::Error>((routing, data))
1072                        });
1073
1074                    // For standard Session data we first reduce the number of SURBs we want to produce,
1075                    // unless requested to always max them out
1076                    let max_out_organic_surbs = cfg.always_max_out_surbs;
1077                    let reduced_surb_scoring_sender = full_surb_scoring_sender.clone().with(
1078                        // NOTE: this is put in-front of the `full_surb_scoring_sender`,
1079                        // so that its estimate of SURBs gets automatically updated based on
1080                        // the `max_surbs_in_packets` set here.
1081                        move |(routing, mut data): (DestinationRouting, ApplicationDataOut)| {
1082                            if !max_out_organic_surbs {
1083                                // TODO: make this dynamic to honor the balancer target (#7439)
1084                                data.packet_info
1085                                    .get_or_insert_with(|| OutgoingPacketInfo {
1086                                        max_surbs_in_packet: 1,
1087                                        ..Default::default()
1088                                    })
1089                                    .max_surbs_in_packet = 1;
1090                            }
1091                            futures::future::ok::<_, S::Error>((routing, data))
1092                        },
1093                    );
1094
1095                    let surb_mgmt = Arc::new(BalancerStateValues::from(balancer_config));
1096                    // The counterparty's store is the same bounded ring buffer as ours, so its
1097                    // capacity bounds what our `produced - consumed` estimate can legitimately
1098                    // claim it is holding.
1099                    surb_mgmt.set_counterparty_buffer_capacity(self.cfg.maximum_surb_buffer_size as u64);
1100
1101                    // Spawn the SURB-bearing keep alive stream towards the Exit
1102                    let (ka_controller, ka_abort_handle) = utils::spawn_keep_alive_stream(
1103                        session_id,
1104                        full_surb_scoring_sender,
1105                        forward_routing.clone(),
1106                        if self.cfg.surb_target_notify {
1107                            SurbNotificationMode::Target
1108                        } else {
1109                            SurbNotificationMode::DoNotNotify
1110                        },
1111                        surb_mgmt.clone(),
1112                    );
1113                    abort_handles.insert(SessionHandles::KeepAlive, ka_abort_handle);
1114
1115                    // Spawn the SURB balancer, which will decide on the initial SURB rate.
1116                    debug!(%session_id, ?balancer_config ,"spawning entry SURB balancer");
1117                    let balancer = SurbBalancer::new(
1118                        session_id,
1119                        // The setpoint and output limit is immediately reconfigured by the SurbBalancer
1120                        PidBalancerController::from_gains(PidControllerGains::from_env_or_default()),
1121                        surb_estimator.clone(),
1122                        // Currently, a keep-alive message can bear `HoprPacket::MAX_SURBS_IN_PACKET` SURBs,
1123                        // so the correction by this factor is applied.
1124                        SurbControllerWithCorrection(ka_controller, HoprPacket::MAX_SURBS_IN_PACKET as u32),
1125                        surb_mgmt.clone(),
1126                    );
1127
1128                    let (level_stream, balancer_abort_handle) =
1129                        balancer.start_control_loop(self.cfg.balancer_sampling_interval);
1130                    abort_handles.insert(SessionHandles::Balancer, balancer_abort_handle);
1131
1132                    // Insert the slot before the SURB readiness wait so any echo packets that
1133                    // arrive during pre-loading are accepted rather than dropped as unknown.
1134                    // Early return from the wait below drops slot_guard uncommitted, which removes
1135                    // the slot and calls close_session → abort_all() on the spawned tasks.
1136                    let mut slot_guard = self
1137                        .allocate_session_slot(
1138                            session_id,
1139                            SessionSlot {
1140                                session_tx,
1141                                routing_opts: forward_routing.clone(),
1142                                abort_handles: Arc::new(parking_lot::Mutex::new(abort_handles)),
1143                                surb_mgmt: surb_mgmt.clone(),
1144                                surb_estimator: surb_estimator.clone(),
1145                            },
1146                        )
1147                        .ok_or_else(|| {
1148                            // Session already exists; it means it is most likely a loopback attempt
1149                            error!(%session_id, "session already exists - loopback attempt");
1150                            SessionManagerError::Loopback
1151                        })?;
1152
1153                    // Prevent the slot from being evicted by time_to_idle while SURBs are
1154                    // pre-loading. Each `get()` call resets the idle timer; the task is
1155                    // aborted as soon as the readiness wait resolves.
1156                    let sessions_keepalive = self.sessions.clone();
1157                    let touch_period = (self.cfg.idle_timeout / 2).max(self.cfg.min_session_touch_period);
1158                    let slot_keepalive = hopr_utils::runtime::prelude::spawn(async move {
1159                        loop {
1160                            hopr_utils::runtime::prelude::sleep(touch_period).await;
1161                            let _ = sessions_keepalive.get(&session_id);
1162                        }
1163                    });
1164
1165                    // TODO: consider making this interactive = other party reports the exact level periodically
1166                    let wait_result = level_stream
1167                        .skip_while(|current_level| {
1168                            futures::future::ready(*current_level < balancer_config.target_surb_buffer_size / 2)
1169                        })
1170                        .next()
1171                        .timeout(futures_time::time::Duration::from(SESSION_READINESS_TIMEOUT))
1172                        .await;
1173                    slot_keepalive.abort();
1174                    match wait_result {
1175                        Ok(Some(surb_level)) => {
1176                            info!(%session_id, surb_level, "session is ready");
1177                        }
1178                        Ok(None) => {
1179                            return Err(
1180                                SessionManagerError::other(anyhow!("surb balancer was cancelled prematurely")).into(),
1181                            );
1182                        }
1183                        Err(_) => {
1184                            warn!(%session_id, "session didn't reach target SURB buffer size in time");
1185                        }
1186                    }
1187
1188                    #[cfg(all(feature = "telemetry", not(test)))]
1189                    METRIC_NUM_INITIATED_SESSIONS.increment();
1190
1191                    let surb_estimator_for_rx = surb_estimator.clone();
1192                    let session = HoprSession::new_with_surb_state(
1193                        session_id,
1194                        forward_routing,
1195                        session_config_with(&self.cfg, cfg.capabilities, cfg.max_frames_behind_gap),
1196                        (
1197                            reduced_surb_scoring_sender,
1198                            session_rx.inspect(move |_| {
1199                                // Received packets = SURB consumption estimate
1200                                // The received packets always consume a single SURB.
1201                                surb_estimator_for_rx
1202                                    .consumed
1203                                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1204                                #[cfg(feature = "telemetry")]
1205                                crate::telemetry::record_session_surb_consumed(&session_id, 1);
1206                            }),
1207                        ),
1208                        Some(notifier),
1209                        // Entry (sending) side: give flow control the SURB balancer state as its
1210                        // anti-grief down-only ceiling, and the client's opt-in flow-control config.
1211                        Some(surb_mgmt.clone()),
1212                        cfg.flow_control,
1213                    )?;
1214
1215                    #[cfg(feature = "telemetry")]
1216                    initialize_session_telemetry(
1217                        session_id,
1218                        &self.cfg,
1219                        cfg.capabilities,
1220                        Some(&surb_estimator),
1221                        Some(&surb_mgmt),
1222                    );
1223
1224                    slot_guard.commit();
1225                    Ok(session)
1226                } else {
1227                    warn!(%session_id, "session ready without SURB balancing");
1228
1229                    // Insert the slot and obtain a guard that rolls it back if any
1230                    // subsequent setup step fails.
1231                    let mut slot_guard = self
1232                        .allocate_session_slot(
1233                            session_id,
1234                            SessionSlot {
1235                                session_tx,
1236                                routing_opts: forward_routing.clone(),
1237                                abort_handles: Arc::new(parking_lot::Mutex::new(abort_handles)),
1238                                surb_mgmt: Default::default(), // Disabled SURB management
1239                                surb_estimator: Default::default(), // No SURB estimator needed
1240                            },
1241                        )
1242                        .ok_or_else(|| {
1243                            // Session already exists; it means it is most likely a loopback attempt
1244                            error!(%session_id, "session already exists - loopback attempt");
1245                            SessionManagerError::Loopback
1246                        })?;
1247
1248                    #[cfg(all(feature = "telemetry", not(test)))]
1249                    METRIC_NUM_INITIATED_SESSIONS.increment();
1250
1251                    // For standard Session data we first reduce the number of SURBs we want to produce,
1252                    // unless requested to always max them out
1253                    let max_out_organic_surbs = cfg.always_max_out_surbs;
1254                    let reduced_surb_sender =
1255                        msg_sender.with(move |(routing, mut data): (DestinationRouting, ApplicationDataOut)| {
1256                            if !max_out_organic_surbs {
1257                                data.packet_info
1258                                    .get_or_insert_with(|| OutgoingPacketInfo {
1259                                        max_surbs_in_packet: 1,
1260                                        ..Default::default()
1261                                    })
1262                                    .max_surbs_in_packet = 1;
1263                            }
1264                            futures::future::ok::<_, S::Error>((routing, data))
1265                        });
1266
1267                    let session = HoprSession::new(
1268                        session_id,
1269                        forward_routing,
1270                        session_config_with(&self.cfg, cfg.capabilities, cfg.max_frames_behind_gap),
1271                        (reduced_surb_sender, session_rx),
1272                        Some(notifier),
1273                    )?;
1274
1275                    #[cfg(feature = "telemetry")]
1276                    initialize_session_telemetry(session_id, &self.cfg, cfg.capabilities, None, None);
1277
1278                    slot_guard.commit();
1279                    Ok(session)
1280                }
1281            }
1282            Ok(Ok(None)) => {
1283                self.session_initiations.remove(&challenge);
1284                Err(SessionManagerError::other(anyhow!(
1285                    "internal error: sender has been closed without completing the session establishment"
1286                ))
1287                .into())
1288            }
1289            Ok(Err(error)) => {
1290                // The other side did not allow us to establish a session
1291                error!(
1292                    challenge = error.challenge,
1293                    ?error,
1294                    "the other party rejected the session initiation with error"
1295                );
1296                Err(TransportSessionError::Rejected(error.reason))
1297            }
1298            Err(_) => {
1299                // Timeout waiting for a session establishment
1300                error!(challenge, "session initiation attempt timed out");
1301
1302                #[cfg(all(feature = "telemetry", not(test)))]
1303                METRIC_RECEIVED_SESSION_ERRS.increment(&["timeout"]);
1304
1305                self.session_initiations.remove(&challenge);
1306                Err(TransportSessionError::Timeout)
1307            }
1308        }
1309    }
1310
1311    /// Sends a keep-alive packet with the given [`SessionId`].
1312    ///
1313    /// This currently "fires & forgets" and does not expect nor await any "pong" response.
1314    pub async fn ping_session(&self, id: &SessionId) -> crate::errors::Result<()> {
1315        if let Some(session_data) = self.sessions.get(id) {
1316            trace!(session_id = ?id, "pinging manually session");
1317            let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
1318            send_via_msg_sender(
1319                &mut msg_sender,
1320                session_data.routing_opts.clone(),
1321                HoprStartProtocol::KeepAlive((*id).into()),
1322                "session ping message",
1323            )
1324            .await
1325            .map_err(TransportSessionError::packet_sending)
1326        } else {
1327            Err(SessionManagerError::NonExistingSession.into())
1328        }
1329    }
1330
1331    /// Returns [`SessionIds`](SessionId) of all currently active sessions.
1332    pub fn active_sessions(&self) -> Vec<SessionId> {
1333        self.sessions.run_pending_tasks();
1334        self.sessions.iter().map(|(k, _)| *k).collect()
1335    }
1336
1337    /// Explicitly closes the session with the given `id`.
1338    ///
1339    /// Removes the entry from the internal session cache, closes the data channel,
1340    /// and aborts any auxiliary tasks. Returns `true` if a session was found and
1341    /// closed, `false` otherwise.
1342    ///
1343    /// This avoids waiting for the idle timeout (`time_to_idle`) or the LRU
1344    /// capacity bound to evict the entry, which is the desired behaviour when
1345    /// the caller (e.g. REST `DELETE /session`) knows the session is finished.
1346    pub fn close_session(&self, id: &SessionId) -> bool {
1347        if let Some(slot) = self.sessions.remove(id) {
1348            self.active_sessions.fetch_sub(1, Ordering::Relaxed);
1349            close_session(*id, slot, ClosureReason::Eviction);
1350            true
1351        } else {
1352            false
1353        }
1354    }
1355
1356    /// Updates the configuration of the SURB balancer on the given [`SessionId`].
1357    ///
1358    /// Returns an error if the Session with the given `id` does not exist, or
1359    /// if it does not use SURB balancing.
1360    pub fn update_surb_balancer_config(&self, id: &SessionId, config: SurbBalancerConfig) -> crate::errors::Result<()> {
1361        let cfg = self
1362            .sessions
1363            .get(id)
1364            .ok_or(SessionManagerError::NonExistingSession)?
1365            .surb_mgmt;
1366
1367        // Only update the config if there already was one before
1368        if !cfg.is_disabled() {
1369            cfg.update(&config);
1370            Ok(())
1371        } else {
1372            Err(SessionManagerError::other(anyhow!("session does not use SURB balancing")).into())
1373        }
1374    }
1375
1376    /// Retrieves the configuration of SURB balancing for the given Session.
1377    ///
1378    /// Returns an error if the Session with the given `id` does not exist.
1379    pub fn get_surb_balancer_config(&self, id: &SessionId) -> crate::errors::Result<Option<SurbBalancerConfig>> {
1380        match self.sessions.get(id) {
1381            Some(session) => Ok(Some(session.surb_mgmt.as_ref())
1382                .filter(|c| !c.is_disabled())
1383                .map(|d| d.as_config())),
1384            None => Err(SessionManagerError::NonExistingSession.into()),
1385        }
1386    }
1387
1388    /// Gets estimations produced/received and consumed SURBs by the Session.
1389    ///
1390    /// For an outgoing Session (Entry) the pair is the number of SURBs sent (by us) and used (by the Exit).
1391    /// For an incoming Session (Exit) the pair is the number of SURBs received (from Entry) and used (by us).
1392    ///
1393    /// Returns an error if the Session with the given `id` does not exist.
1394    pub fn get_surb_level_estimates(&self, id: &SessionId) -> crate::errors::Result<(u64, u64)> {
1395        match self.sessions.get(id) {
1396            Some(session) => Ok((
1397                session
1398                    .surb_estimator
1399                    .produced
1400                    .load(std::sync::atomic::Ordering::Relaxed),
1401                session
1402                    .surb_estimator
1403                    .consumed
1404                    .load(std::sync::atomic::Ordering::Relaxed),
1405            )),
1406            None => Err(SessionManagerError::NonExistingSession.into()),
1407        }
1408    }
1409
1410    /// Marks the return path to `destination` as degraded on every Session routed there.
1411    ///
1412    /// The Session layer cannot tell a dead return path from a peer with nothing to say -- both
1413    /// simply stop consuming SURBs -- so the judgement is made where sibling paths can be compared
1414    /// and delivered here. Sessions that did not opt in ignore the mark; the rest stop trusting
1415    /// their counterparty buffer estimate for `grace`.
1416    ///
1417    /// Returns how many Sessions were marked, which is zero when nothing currently routes there.
1418    pub fn mark_return_path_degraded(
1419        &self,
1420        destination: &hopr_api::types::internal::NodeId,
1421        grace: std::time::Duration,
1422    ) -> usize {
1423        self.sessions
1424            .iter()
1425            .filter(|(_, slot)| {
1426                matches!(&slot.routing_opts, DestinationRouting::Forward { destination: d, .. } if d.as_ref() == destination)
1427            })
1428            .map(|(_, slot)| slot.surb_mgmt.mark_return_path_degraded(grace))
1429            .count()
1430    }
1431
1432    /// The main method to be called whenever data are received.
1433    ///
1434    /// It tries to recognize the message and correctly dispatches either
1435    /// the Session protocol or Start protocol messages.
1436    ///
1437    /// If the data are not recognized, they are returned as [`DispatchResult::Unrelated`].
1438    pub fn dispatch_message(
1439        &self,
1440        pseudonym: HoprPseudonym,
1441        in_data: ApplicationDataIn,
1442    ) -> crate::errors::Result<DispatchResult> {
1443        if in_data.data.application_tag == HoprStartProtocol::START_PROTOCOL_MESSAGE_TAG {
1444            // This is a Start protocol message, so we send it to the handler
1445            trace!("dispatching Start protocol message");
1446            if let Some(start_protocol_tx) = self.start_protocol_tx.get() {
1447                start_protocol_tx
1448                    .try_send((pseudonym, HoprStartProtocol::try_from(in_data.data)?))
1449                    .map_err(|error| {
1450                        error!(%error, "failed to send Start protocol message to processing task");
1451                        SessionManagerError::other(error)
1452                    })?;
1453            } else {
1454                return Err(SessionManagerError::NotStarted.into());
1455            }
1456
1457            #[cfg(all(feature = "telemetry", not(test)))]
1458            METRIC_DISPATCHED_MSGS.increment_by(&["processed"], 1);
1459
1460            return Ok(DispatchResult::Processed);
1461        } else if in_data.data.application_tag == SESSION_APPLICATION_TAG {
1462            let session_id = pseudonym;
1463
1464            // How often a sustained inbox-full condition is allowed to log. Backpressure can
1465            // affect every packet on the hot path, so warning per drop would reproduce exactly
1466            // the ERROR spam this dispatch was rewritten to avoid — warn once per interval instead.
1467            const SESSION_INBOX_FULL_WARN_INTERVAL: usize = 256;
1468
1469            return if let Some(session_slot) = self.sessions.get(&session_id) {
1470                trace!(%session_id, "received data for a registered session");
1471
1472                match session_slot.session_tx.try_send(in_data) {
1473                    Ok(_) => {
1474                        #[cfg(all(feature = "telemetry", not(test)))]
1475                        METRIC_DISPATCHED_MSGS.increment_by(&["processed"], 1);
1476
1477                        Ok(DispatchResult::Processed)
1478                    }
1479                    // Benign teardown race: the slot is still registered but its data sink was
1480                    // dropped. Quiet on purpose — see [`DropReason`].
1481                    Err(crossfire::TrySendError::Disconnected(_)) => {
1482                        trace!(%session_id, "dropping data for a session whose sink has closed");
1483                        crate::counters::SESSION_INBOX_CLOSED_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1484                        #[cfg(all(feature = "telemetry", not(test)))]
1485                        METRIC_DISPATCHED_MSGS.increment_by(&["dropped_sink_closed"], 1);
1486                        Ok(DispatchResult::Dropped(DropReason::SinkClosed))
1487                    }
1488                    // Genuine backpressure: the reader is not keeping up. Rate-limited so a
1489                    // sustained overload cannot become per-packet log spam.
1490                    Err(crossfire::TrySendError::Full(_)) => {
1491                        let prev =
1492                            crate::counters::SESSION_INBOX_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1493                        if prev.is_multiple_of(SESSION_INBOX_FULL_WARN_INTERVAL) {
1494                            warn!(%session_id, total_inbox_full_drops = prev + 1, "session inbox full, dropping data (backpressure)");
1495                        }
1496                        #[cfg(all(feature = "telemetry", not(test)))]
1497                        METRIC_DISPATCHED_MSGS.increment_by(&["dropped_sink_full"], 1);
1498                        Ok(DispatchResult::Dropped(DropReason::SinkFull))
1499                    }
1500                }
1501            } else {
1502                // Fully deregistered session: packets still in flight for a torn-down session.
1503                // Quiet — see [`DropReason::Unregistered`].
1504                trace!(%session_id, "dropping data for an unregistered session");
1505                crate::counters::SESSION_UNKNOWN_DATA_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1506                #[cfg(all(feature = "telemetry", not(test)))]
1507                METRIC_DISPATCHED_MSGS.increment_by(&["dropped_unregistered"], 1);
1508                Ok(DispatchResult::Dropped(DropReason::Unregistered))
1509            };
1510        }
1511
1512        trace!(tag = %in_data.data.application_tag, "received data not associated with session protocol or any existing session");
1513
1514        crate::counters::SESSION_UNRELATED_DATA_DISPATCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1515
1516        #[cfg(all(feature = "telemetry", not(test)))]
1517        METRIC_DISPATCHED_MSGS.increment_by(&["unrelated"], 1);
1518
1519        Ok(DispatchResult::Unrelated(in_data))
1520    }
1521
1522    /// Pre-populates the sessions cache with a session slot for benchmarking.
1523    ///
1524    /// Intended for benchmarks that need a session to exist before calling
1525    /// [`SessionManager::dispatch_message`].
1526    ///
1527    /// Requires the `"benchmark"` feature (or a test build).
1528    #[cfg(any(feature = "benchmark", test))]
1529    pub fn pre_populate_session(&self, session_id: SessionId, routing_opts: DestinationRouting) {
1530        let (session_tx, _) =
1531            crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
1532        let slot = SessionSlot {
1533            session_tx,
1534            routing_opts,
1535            abort_handles: Default::default(),
1536            surb_mgmt: Arc::new(BalancerStateValues::default()),
1537            surb_estimator: Default::default(),
1538        };
1539        self.sessions.insert(session_id, slot);
1540    }
1541
1542    /// Like [`pre_populate_session`](SessionManager::pre_populate_session) but also returns the
1543    /// session channel receiver so the caller can spawn a drain task.
1544    ///
1545    /// Requires the `"benchmark"` feature (or a test build).
1546    #[cfg(any(feature = "benchmark", test))]
1547    pub fn pre_populate_session_with_receiver(
1548        &self,
1549        session_id: SessionId,
1550        routing_opts: DestinationRouting,
1551    ) -> crossfire::AsyncRx<crossfire::mpsc::Array<ApplicationDataIn>> {
1552        let (session_tx, session_rx) =
1553            crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
1554        let slot = SessionSlot {
1555            session_tx,
1556            routing_opts,
1557            abort_handles: Default::default(),
1558            surb_mgmt: Arc::new(BalancerStateValues::default()),
1559            surb_estimator: Default::default(),
1560        };
1561        self.sessions.insert(session_id, slot);
1562        session_rx
1563    }
1564
1565    async fn handle_incoming_session_initiation(
1566        &self,
1567        pseudonym: HoprPseudonym,
1568        session_req: StartInitiation<SessionTarget, ByteCapabilities>,
1569    ) -> crate::errors::Result<()> {
1570        trace!(challenge = session_req.challenge, "received session initiation request");
1571
1572        debug!(%pseudonym, "got new session request, searching for a free session slot");
1573
1574        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
1575
1576        let (new_session_notifier, close_session_notifier) = self
1577            .session_notifiers
1578            .get()
1579            .cloned()
1580            .ok_or(SessionManagerError::NotStarted)?;
1581
1582        // Reply routing uses SURBs only with the pseudonym of this Session's ID
1583        let reply_routing = DestinationRouting::Return(pseudonym.into());
1584
1585        // Use constant application tag for all sessions
1586        self.sessions.run_pending_tasks();
1587
1588        // A repeated initiation for a pseudonym that already has a Session means the
1589        // initiator has lost or abandoned its side of it (it never received our
1590        // SessionEstablished reply, or it reuses its pseudonym on reconnect).
1591        // The pseudonym is known only to the initiator, so the existing Session cannot
1592        // serve anyone else anymore: close it and let this initiation take the slot over.
1593        // Otherwise, re-initiations would keep being rejected with NoSlotsAvailable
1594        // until the stale Session gets evicted by the idle timeout.
1595        if let Some(stale_slot) = self.sessions.remove(&pseudonym) {
1596            self.active_sessions.fetch_sub(1, Ordering::Relaxed);
1597            info!(%pseudonym, "closing stale session superseded by a new initiation with the same pseudonym");
1598            close_session(pseudonym, stale_slot, ClosureReason::Eviction);
1599        }
1600
1601        let session_id = pseudonym;
1602
1603        let (session_tx, session_rx) =
1604            crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
1605        let (session_rx, session_rx_ah) = hopr_utils::runtime::DropAbortable::new(session_rx.into_stream());
1606
1607        let slot = SessionSlot {
1608            session_tx,
1609            routing_opts: reply_routing.clone(),
1610            abort_handles: Default::default(),
1611            surb_mgmt: Default::default(),
1612            surb_estimator: Default::default(),
1613        };
1614        slot.abort_handles.lock().insert(SessionHandles::Ingress, session_rx_ah);
1615
1616        // Insert the slot and obtain a guard. Any failure from here on rolls the slot
1617        // back, otherwise it would block this pseudonym until idle eviction. The atomic
1618        // insert (inside the helper) also prevents a TOCTOU race, so only one concurrent
1619        // request can claim the slot for a given pseudonym.
1620        let Some(mut slot_guard) = self.allocate_session_slot(session_id, slot.clone()) else {
1621            // Either the maximum number of sessions has been reached, or a concurrent
1622            // initiation for the same pseudonym has claimed the slot first.
1623            error!(%pseudonym, "no session slot available");
1624            let reason = StartErrorReason::NoSlotsAvailable;
1625            let data = HoprStartProtocol::SessionError(StartErrorType {
1626                challenge: session_req.challenge,
1627                reason,
1628            });
1629            send_via_msg_sender(&mut msg_sender, reply_routing.clone(), data, "session error message").await?;
1630            return Ok(());
1631        };
1632
1633        debug!(?pseudonym, ?session_req, "assigned a new session");
1634
1635        let closure_notifier = Box::new(move |session_id: SessionId, reason: ClosureReason| {
1636            if let Err(error) = close_session_notifier.try_send((session_id, reason)) {
1637                error!(%session_id, %error, %reason, "failed to notify session closure");
1638            }
1639        });
1640
1641        let session = if !session_req.capabilities.0.contains(Capability::NoRateControl) {
1642            // Because of SURB scarcity, control the egress rate of incoming sessions
1643            let egress_rate_control =
1644                RateController::new(self.cfg.initial_return_session_egress_rate, Duration::from_secs(1));
1645
1646            // The Session request carries a "hint" as additional data telling what
1647            // the Session initiator has configured as its target buffer size in the Balancer.
1648            let target_surb_buffer_size = if session_req.additional_data > 0 {
1649                (session_req.additional_data as u64).min(self.cfg.maximum_surb_buffer_size as u64)
1650            } else {
1651                self.cfg.initial_return_session_egress_rate as u64
1652                    * self
1653                        .cfg
1654                        .minimum_surb_buffer_duration
1655                        .max(MIN_SURB_BUFFER_DURATION)
1656                        .as_secs()
1657            };
1658
1659            let surb_estimator_clone = slot.surb_estimator.clone();
1660            let session = HoprSession::new(
1661                session_id,
1662                reply_routing.clone(),
1663                session_config(&self.cfg, session_req.capabilities.into()),
1664                (
1665                    // Sent packets = SURB consumption estimate
1666                    msg_sender
1667                        .clone()
1668                        .with(move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
1669                            // Each outgoing packet consumes one SURB
1670                            surb_estimator_clone
1671                                .consumed
1672                                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1673                            #[cfg(feature = "telemetry")]
1674                            crate::telemetry::record_session_surb_consumed(&session_id, 1);
1675                            futures::future::ok::<_, S::Error>((routing, data))
1676                        })
1677                        .rate_limit_with_controller(&egress_rate_control)
1678                        .buffer((2 * target_surb_buffer_size) as usize),
1679                    // Received packets = SURB retrieval estimate
1680                    session_rx.inspect(move |data| {
1681                        let produced = data.num_surbs_with_msg() as u64;
1682                        // Count the number of SURBs delivered with each incoming packet
1683                        surb_estimator_clone
1684                            .produced
1685                            .fetch_add(produced, std::sync::atomic::Ordering::Relaxed);
1686                        #[cfg(feature = "telemetry")]
1687                        crate::telemetry::record_session_surb_produced(&session_id, produced);
1688                    }),
1689                ),
1690                Some(closure_notifier),
1691            )?;
1692
1693            // The SURB balancer will start intervening by rate-limiting the
1694            // egress of the Session, once the estimated number of SURBs drops below
1695            // the target defined here. Otherwise, the maximum egress is allowed.
1696            let balancer_config = SurbBalancerConfig {
1697                target_surb_buffer_size,
1698                // At maximum egress, the SURB buffer drains in `minimum_surb_buffer_duration` seconds
1699                max_surbs_per_sec: target_surb_buffer_size / self.cfg.minimum_surb_buffer_duration.as_secs(),
1700                // No SURB decay at the Exit, since we know almost exactly how many SURBs
1701                // were received
1702                surb_decay: None,
1703                sustain_on_return_path_loss: false,
1704            };
1705
1706            slot.surb_mgmt.update(&balancer_config);
1707            slot.surb_mgmt
1708                .set_counterparty_buffer_capacity(self.cfg.maximum_surb_buffer_size as u64);
1709
1710            // Spawn the SURB balancer only once we know we have registered the
1711            // abort handle with the pre-allocated Session slot
1712            debug!(%session_id, ?balancer_config ,"spawning exit SURB balancer");
1713            let balancer = SurbBalancer::new(
1714                session_id,
1715                SimpleBalancerController::default(),
1716                slot.surb_estimator.clone(),
1717                SurbControllerWithCorrection(egress_rate_control, 1), // 1 SURB per egress packet
1718                slot.surb_mgmt.clone(),
1719            );
1720
1721            // Assign the SURB balancer and abort handles to the already allocated Session slot
1722            let (_, balancer_abort_handle) = balancer.start_control_loop(self.cfg.balancer_sampling_interval);
1723            slot.abort_handles
1724                .lock()
1725                .insert(SessionHandles::Balancer, balancer_abort_handle);
1726
1727            // Spawn a keep-alive stream notifying about the SURB buffer level towards the Entry
1728            if let Some(period) = self.cfg.surb_balance_notify_period {
1729                let surb_estimator_clone = slot.surb_estimator.clone();
1730                let (ka_controller, ka_abort_handle) = utils::spawn_keep_alive_stream(
1731                    session_id,
1732                    // Sent Keep-Alive packets also contribute to SURB consumption
1733                    msg_sender
1734                        .clone()
1735                        .with(move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
1736                            // Each sent keepalive consumes 1 SURB
1737                            surb_estimator_clone
1738                                .consumed
1739                                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1740                            #[cfg(feature = "telemetry")]
1741                            crate::telemetry::record_session_surb_consumed(&session_id, 1);
1742                            futures::future::ok::<_, S::Error>((routing, data))
1743                        }),
1744                    slot.routing_opts.clone(),
1745                    SurbNotificationMode::Level(slot.surb_estimator.clone()),
1746                    slot.surb_mgmt.clone(),
1747                );
1748
1749                // Start keepalive stream towards the Entry with a predefined period
1750                hopr_utils::runtime::prelude::spawn(async move {
1751                    // Delay the stream execution by one period
1752                    hopr_utils::runtime::prelude::sleep(period).await;
1753                    ka_controller.set_rate_per_unit(1, period);
1754                });
1755
1756                slot.abort_handles
1757                    .lock()
1758                    .insert(SessionHandles::KeepAlive, ka_abort_handle);
1759
1760                debug!(%session_id, ?period, "started SURB level-notifying keep-alive stream");
1761            }
1762
1763            session
1764        } else {
1765            HoprSession::new(
1766                session_id,
1767                reply_routing.clone(),
1768                session_config(&self.cfg, session_req.capabilities.into()),
1769                (msg_sender.clone(), session_rx),
1770                Some(closure_notifier),
1771            )?
1772        };
1773
1774        // Extract useful information about the session from the Start protocol message
1775        let incoming_session = IncomingSession {
1776            id: session_id,
1777            session,
1778            target: session_req.target,
1779        };
1780
1781        // Notify that a new incoming session has been created. Lock the sink and send
1782        // directly into it, so no extra forwarding task between channels is needed.
1783        match async {
1784            let mut guard = new_session_notifier.lock().await;
1785            guard.send(incoming_session).await
1786        }
1787        .timeout(futures_time::time::Duration::from(EXTERNAL_SEND_TIMEOUT))
1788        .await
1789        {
1790            Err(_) => {
1791                error!(%session_id, "timeout to notify about new incoming session");
1792                return Err(TransportSessionError::Timeout);
1793            }
1794            Ok(Err(error)) => {
1795                error!(%session_id, %error, "failed to notify about new incoming session");
1796                return Err(SessionManagerError::other(error).into());
1797            }
1798            _ => {}
1799        };
1800
1801        trace!(?session_id, "session notification sent");
1802
1803        // Notify the sender that the session has been established.
1804        // Set our peer ID in the session ID sent back to them.
1805        let data = HoprStartProtocol::SessionEstablished(StartEstablished {
1806            orig_challenge: session_req.challenge,
1807            session_id,
1808        });
1809
1810        send_via_msg_sender(
1811            &mut msg_sender,
1812            reply_routing.clone(),
1813            data,
1814            "session establishment message",
1815        )
1816        .await?;
1817
1818        #[cfg(feature = "telemetry")]
1819        initialize_session_telemetry(
1820            session_id,
1821            &self.cfg,
1822            session_req.capabilities.0,
1823            Some(&slot.surb_estimator),
1824            Some(&slot.surb_mgmt),
1825        );
1826
1827        info!(%session_id, "new session established");
1828
1829        #[cfg(all(feature = "telemetry", not(test)))]
1830        METRIC_NUM_ESTABLISHED_SESSIONS.increment();
1831
1832        slot_guard.commit();
1833        Ok(())
1834    }
1835
1836    async fn handle_session_established(&self, est: StartEstablished<SessionId>) -> crate::errors::Result<()> {
1837        trace!(
1838            session_id = ?est.session_id,
1839            "received session establishment confirmation"
1840        );
1841        let challenge = est.orig_challenge;
1842        let session_id = est.session_id;
1843        if let Some(tx_est) = self.session_initiations.remove(&est.orig_challenge) {
1844            if let Err(error) = tx_est.try_send(Ok(est)) {
1845                error!(%challenge, %session_id, %error, "failed to send session establishment confirmation");
1846                return Err(SessionManagerError::other(error).into());
1847            }
1848            debug!(?session_id, challenge, "session establishment complete");
1849        } else {
1850            error!(%session_id, challenge, "unknown session establishment attempt or expired");
1851        }
1852        Ok(())
1853    }
1854
1855    async fn handle_session_error(&self, error_type: StartErrorType) -> crate::errors::Result<()> {
1856        trace!(
1857            challenge = error_type.challenge,
1858            error = ?error_type.reason,
1859            "failed to initialize a session",
1860        );
1861        // Currently, we do not distinguish between individual error types
1862        // and just discard the initiation attempt and pass on the error.
1863        if let Some(tx_est) = self.session_initiations.remove(&error_type.challenge) {
1864            if let Err(error) = tx_est.try_send(Err(error_type)) {
1865                error!(%error, ?error_type, "could not send session error message");
1866                return Err(SessionManagerError::other(error).into());
1867            }
1868            error!(
1869                challenge = error_type.challenge,
1870                ?error_type,
1871                "session establishment error received"
1872            );
1873        } else {
1874            error!(
1875                challenge = error_type.challenge,
1876                ?error_type,
1877                "session establishment attempt expired before error could be delivered"
1878            );
1879        }
1880
1881        #[cfg(all(feature = "telemetry", not(test)))]
1882        METRIC_RECEIVED_SESSION_ERRS.increment(&[&error_type.reason.to_string()]);
1883
1884        Ok(())
1885    }
1886
1887    async fn handle_keep_alive(&self, msg: KeepAliveMessage<SessionId>) -> crate::errors::Result<()> {
1888        let session_id = msg.session_id;
1889        if let Some(session_slot) = self.sessions.get(&session_id) {
1890            trace!(?session_id, "received keep-alive message");
1891            match &session_slot.routing_opts {
1892                // Session is outgoing - keep-alive was received from the Exit
1893                DestinationRouting::Forward { .. } => {
1894                    if msg.flags.contains(KeepAliveFlag::BalancerState)
1895                        && !session_slot.surb_mgmt.is_disabled()
1896                        && session_slot.surb_mgmt.buffer_level() != msg.additional_data
1897                    {
1898                        // Update the buffer level as sent to us from the Exit
1899                        session_slot
1900                            .surb_mgmt
1901                            .buffer_level
1902                            .store(msg.additional_data, std::sync::atomic::Ordering::Relaxed);
1903                        debug!(%session_id, surb_level = msg.additional_data, "keep-alive updated SURB buffer size from the Exit");
1904                    }
1905
1906                    // Increase the number of consumed SURBs in the estimator
1907                    session_slot
1908                        .surb_estimator
1909                        .consumed
1910                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1911                    #[cfg(feature = "telemetry")]
1912                    crate::telemetry::record_session_surb_consumed(&session_id, 1);
1913                }
1914                // Session is incoming - keep-alive was received from the Entry
1915                DestinationRouting::Return(_) => {
1916                    // Allow updating SURB balancer target based on the received Keep-Alive message
1917                    if msg.flags.contains(KeepAliveFlag::BalancerTarget)
1918                        && msg.additional_data > 0
1919                        && !session_slot.surb_mgmt.is_disabled()
1920                        && session_slot.surb_mgmt.controller_bounds().target() != msg.additional_data
1921                    {
1922                        // Update the target buffer size as sent to us from the Entry
1923                        session_slot
1924                            .surb_mgmt
1925                            .target_surb_buffer_size
1926                            .store(msg.additional_data, std::sync::atomic::Ordering::Relaxed);
1927                        // Update maximum SURBs per second based on the new target
1928                        session_slot.surb_mgmt.max_surbs_per_sec.store(
1929                            msg.additional_data / self.cfg.minimum_surb_buffer_duration.as_secs(),
1930                            std::sync::atomic::Ordering::Relaxed,
1931                        );
1932                        debug!(%session_id, target_surb_buffer_size = msg.additional_data, "keep-alive updated SURB balancer target buffer size from the Entry");
1933                    }
1934
1935                    // Increase the number of received SURBs in the estimator.
1936                    // Typically, 2 SURBs per Keep-Alive message
1937                    let produced = KeepAliveMessage::<SessionId>::MIN_SURBS_PER_MESSAGE as u64;
1938                    session_slot
1939                        .surb_estimator
1940                        .produced
1941                        .fetch_add(produced, std::sync::atomic::Ordering::Relaxed);
1942                    #[cfg(feature = "telemetry")]
1943                    crate::telemetry::record_session_surb_produced(&session_id, produced);
1944                }
1945            }
1946        } else {
1947            debug!(%session_id, "received keep-alive request for an unknown session");
1948        }
1949        Ok(())
1950    }
1951}
1952
1953#[cfg(test)]
1954mod tests {
1955    use anyhow::{Context, anyhow};
1956    use futures::{AsyncWriteExt, channel::mpsc::UnboundedSender, future::BoxFuture, pin_mut};
1957    use hopr_api::types::{
1958        crypto::{keypairs::ChainKeypair, prelude::Keypair},
1959        crypto_random::Randomizable,
1960        internal::routing::SurbMatcher,
1961        primitive::prelude::Address,
1962    };
1963    use hopr_protocol_start::{StartProtocol, StartProtocolDiscriminants};
1964    use hopr_utils::network_types::prelude::SealedHost;
1965    use moka::future::FutureExt;
1966    use tokio::time::timeout;
1967
1968    use super::*;
1969    use crate::{Capabilities, balancer::SurbBalancerConfig, types::SessionTarget};
1970
1971    #[test]
1972    fn session_config_forwards_max_buffered_segments() {
1973        assert_eq!(
1974            SessionManagerConfig::default().max_buffered_segments,
1975            0,
1976            "default must leave the transport unbuffered"
1977        );
1978
1979        for segments in [0, 64] {
1980            let cfg = SessionManagerConfig {
1981                max_buffered_segments: segments,
1982                ..Default::default()
1983            };
1984            assert_eq!(
1985                session_config(&cfg, Capabilities::empty()).max_buffered_segments,
1986                segments
1987            );
1988        }
1989    }
1990
1991    /// The head-of-line bound must reach a session that cannot recover a missing frame, and must
1992    /// not reach one that can.
1993    ///
1994    /// Both halves matter. Without the first, a session with no retransmission holds every frame
1995    /// behind a gap for the full frame timeout, waiting for something that is never coming —
1996    /// measured on a cluster as 98.5 % of bytes arriving over the wire and 0.60 % reaching the
1997    /// application. Without the second, a session that *would* have retransmitted the gap instead
1998    /// abandons it, turning recoverable frames into loss.
1999    #[test]
2000    fn session_config_should_bound_the_gap_only_without_retransmission() {
2001        assert_eq!(
2002            SessionManagerConfig::default().max_frames_behind_gap,
2003            Some(256),
2004            "the default must bound the gap, or the stall stays in place unless opted out of"
2005        );
2006
2007        let cfg = SessionManagerConfig {
2008            max_frames_behind_gap: Some(8),
2009            ..Default::default()
2010        };
2011
2012        for reliable in [Capability::RetransmissionAck, Capability::RetransmissionNack] {
2013            assert_eq!(
2014                session_config(&cfg, reliable.into()).max_frames_behind_gap,
2015                None,
2016                "{reliable:?} can recover the gap, so the wait is productive and must be left alone"
2017            );
2018        }
2019
2020        for unreliable in [Capabilities::empty(), Capability::Segmentation.into()] {
2021            assert_eq!(
2022                session_config(&cfg, unreliable).max_frames_behind_gap,
2023                Some(8),
2024                "without retransmission the gap must be bounded"
2025            );
2026        }
2027    }
2028
2029    /// The right value tracks throughput x latency spread, which is a property of the *session*,
2030    /// not of the node: a bulk data session and a control session on the same node have entirely
2031    /// different reordering depths. A caller that knows its own traffic must be able to say so.
2032    #[test]
2033    fn a_session_should_be_able_to_override_the_nodes_gap_bound() {
2034        let node = SessionManagerConfig {
2035            max_frames_behind_gap: Some(256),
2036            ..Default::default()
2037        };
2038
2039        assert_eq!(
2040            session_config_with(&node, Capabilities::empty(), Some(16)).max_frames_behind_gap,
2041            Some(16),
2042            "the session's own value must win over the node default"
2043        );
2044        assert_eq!(
2045            session_config_with(&node, Capabilities::empty(), None).max_frames_behind_gap,
2046            Some(256),
2047            "saying nothing must inherit the node default"
2048        );
2049        assert_eq!(
2050            session_config_with(&node, Capabilities::empty(), Some(0)).max_frames_behind_gap,
2051            None,
2052            "zero disables the bound for this session, matching the env knob's semantics"
2053        );
2054    }
2055
2056    /// A per-session override must not be able to re-enable the bound where waiting is productive.
2057    #[test]
2058    fn a_session_override_should_not_reach_a_session_that_can_retransmit() {
2059        let node = SessionManagerConfig::default();
2060        assert_eq!(
2061            session_config_with(&node, Capability::RetransmissionAck.into(), Some(4)).max_frames_behind_gap,
2062            None,
2063            "retransmission can recover the gap, so no caller should be able to cut the wait short"
2064        );
2065    }
2066
2067    /// Disabling has to be reachable, since the bound trades reordering tolerance for latency and
2068    /// the right value is deployment-specific. `None` restores the previous behaviour exactly.
2069    #[test]
2070    fn session_config_should_allow_the_gap_bound_to_be_disabled() {
2071        let cfg = SessionManagerConfig {
2072            max_frames_behind_gap: None,
2073            ..Default::default()
2074        };
2075        assert_eq!(session_config(&cfg, Capabilities::empty()).max_frames_behind_gap, None);
2076    }
2077
2078    #[test]
2079    fn a_zero_gap_bound_should_disable_it_at_the_node_level_too() {
2080        // `0` means "not for me" wherever it is written. Read literally it would be the strictest
2081        // possible bound -- abandon the gap before a single frame arrives behind it -- so the two
2082        // levels would mean opposite things by the same value.
2083        let cfg = SessionManagerConfig {
2084            max_frames_behind_gap: Some(0),
2085            ..Default::default()
2086        };
2087        assert_eq!(session_config(&cfg, Capabilities::empty()).max_frames_behind_gap, None);
2088    }
2089
2090    #[async_trait::async_trait]
2091    trait SendMsg {
2092        async fn send_message(
2093            &self,
2094            routing: DestinationRouting,
2095            data: ApplicationDataOut,
2096        ) -> crate::errors::Result<()>;
2097    }
2098
2099    mockall::mock! {
2100        MsgSender {}
2101        impl SendMsg for MsgSender {
2102            fn send_message<'a, 'b>(&'a self, routing: DestinationRouting, data: ApplicationDataOut)
2103            -> BoxFuture<'b, crate::errors::Result<()>> where 'a: 'b, Self: Sync + 'b;
2104        }
2105    }
2106
2107    fn mock_packet_planning(
2108        sender: MockMsgSender,
2109    ) -> (
2110        UnboundedSender<(DestinationRouting, ApplicationDataOut)>,
2111        tokio::task::JoinHandle<()>,
2112    ) {
2113        let (tx, rx) = futures::channel::mpsc::unbounded();
2114        let handle = tokio::task::spawn(async move {
2115            pin_mut!(rx);
2116            while let Some((routing, data)) = rx.next().await {
2117                sender
2118                    .send_message(routing, data)
2119                    .await
2120                    .expect("send message must not fail in mock");
2121            }
2122        });
2123        (tx, handle)
2124    }
2125
2126    fn msg_type(data: &ApplicationDataOut, expected: StartProtocolDiscriminants) -> bool {
2127        HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
2128            .map(|d| StartProtocolDiscriminants::from(d) == expected)
2129            .unwrap_or(false)
2130    }
2131
2132    fn start_msg_match(data: &ApplicationDataOut, msg: impl Fn(HoprStartProtocol) -> bool) -> bool {
2133        HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
2134            .map(msg)
2135            .unwrap_or(false)
2136    }
2137
2138    /// Waits (bounded) until the manager reports no active sessions.
2139    ///
2140    /// The session-slot rollback runs on a spawned task, so its effect is observed
2141    /// asynchronously; this polls [`SessionManager::active_sessions`] until it drains.
2142    async fn wait_for_no_active_sessions(
2143        mgr: &SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>,
2144    ) -> bool {
2145        for _ in 0..50 {
2146            if mgr.active_sessions().is_empty() {
2147                return true;
2148            }
2149            tokio::time::sleep(Duration::from_millis(20)).await;
2150        }
2151        mgr.active_sessions().is_empty()
2152    }
2153
2154    #[test_log::test(tokio::test)]
2155    async fn session_manager_should_follow_start_protocol_to_establish_new_session_and_close_it() -> anyhow::Result<()>
2156    {
2157        let alice_pseudonym = HoprPseudonym::random();
2158        let bob_peer: Address = (&ChainKeypair::random()).into();
2159
2160        let alice_mgr = SessionManager::new(Default::default());
2161        let bob_mgr = SessionManager::new(Default::default());
2162
2163        let mut sequence = mockall::Sequence::new();
2164        let mut alice_transport = MockMsgSender::new();
2165        let mut bob_transport = MockMsgSender::new();
2166
2167        // Alice sends the StartSession message
2168        let bob_mgr_clone = bob_mgr.clone();
2169        alice_transport
2170            .expect_send_message()
2171            .once()
2172            .in_sequence(&mut sequence)
2173            .withf(move |peer, data| {
2174                info!("alice sends {}", data.data.application_tag);
2175                msg_type(data, StartProtocolDiscriminants::StartSession)
2176                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
2177            })
2178            .returning(move |_, data| {
2179                let bob_mgr_clone = bob_mgr_clone.clone();
2180                Box::pin(async move {
2181                    bob_mgr_clone
2182                        .dispatch_message(
2183                            alice_pseudonym,
2184                            ApplicationDataIn {
2185                                data: data.data,
2186                                packet_info: Default::default(),
2187                            },
2188                        )
2189                        ?;
2190                    Ok(())
2191                })
2192            });
2193
2194        // Bob sends the SessionEstablished message
2195        let alice_mgr_clone = alice_mgr.clone();
2196        bob_transport
2197            .expect_send_message()
2198            .once()
2199            .in_sequence(&mut sequence)
2200            .withf(move |peer, data| {
2201                info!("bob sends {}", data.data.application_tag);
2202                msg_type(data, StartProtocolDiscriminants::SessionEstablished)
2203                    && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if p == &alice_pseudonym)
2204            })
2205            .returning(move |_, data| {
2206                let alice_mgr_clone = alice_mgr_clone.clone();
2207
2208                Box::pin(async move {
2209                    alice_mgr_clone.dispatch_message(
2210                        alice_pseudonym,
2211                        ApplicationDataIn {
2212                            data: data.data,
2213                            packet_info: Default::default(),
2214                        },
2215                    )?;
2216                    Ok(())
2217                })
2218            });
2219
2220        // Alice sends the terminating segment to close the Session
2221        let bob_mgr_clone = bob_mgr.clone();
2222        alice_transport
2223            .expect_send_message()
2224            .once()
2225            .in_sequence(&mut sequence)
2226            .withf(move |peer, data| {
2227                hopr_protocol_session::types::SessionMessage::<{ ApplicationData::PAYLOAD_SIZE }>::try_from(
2228                    data.data.plain_text.as_ref(),
2229                )
2230                .expect("must be a session message")
2231                .try_as_segment()
2232                .expect("must be a segment")
2233                .is_terminating()
2234                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
2235            })
2236            .returning(move |_, data| {
2237                let bob_mgr_clone = bob_mgr_clone.clone();
2238                Box::pin(async move {
2239                    bob_mgr_clone
2240                        .dispatch_message(
2241                            alice_pseudonym,
2242                            ApplicationDataIn {
2243                                data: data.data,
2244                                packet_info: Default::default(),
2245                            },
2246                        )
2247                        ?;
2248                    Ok(())
2249                })
2250            });
2251
2252        let mut ahs = Vec::new();
2253
2254        // Start Alice
2255        let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
2256        let (alice_sender, alice_handle) = mock_packet_planning(alice_transport);
2257        ahs.extend(alice_mgr.start(alice_sender.clone(), new_session_tx_alice)?);
2258        assert!(alice_mgr.is_started());
2259
2260        // Start Bob
2261        let (new_session_tx_bob, new_session_rx_bob) = futures::channel::mpsc::channel(1024);
2262        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
2263        ahs.extend(bob_mgr.start(bob_sender.clone(), new_session_tx_bob)?);
2264        assert!(bob_mgr.is_started());
2265
2266        let target = SealedHost::Plain("127.0.0.1:80".parse()?);
2267
2268        pin_mut!(new_session_rx_bob);
2269        let (alice_session, bob_session) = timeout(
2270            Duration::from_secs(2),
2271            futures::future::join(
2272                alice_mgr.new_session(
2273                    bob_peer,
2274                    SessionTarget::TcpStream(target.clone()),
2275                    SessionClientConfig {
2276                        pseudonym: alice_pseudonym.into(),
2277                        capabilities: Capability::NoRateControl | Capability::Segmentation,
2278                        surb_management: None,
2279                        ..Default::default()
2280                    },
2281                ),
2282                new_session_rx_bob.next(),
2283            ),
2284        )
2285        .await?;
2286
2287        let mut alice_session = alice_session?;
2288        let bob_session = bob_session.ok_or(anyhow!("bob must get an incoming session"))?;
2289
2290        assert_eq!(
2291            alice_session.config().capabilities,
2292            Capability::Segmentation | Capability::NoRateControl
2293        );
2294        assert_eq!(
2295            alice_session.config().capabilities,
2296            bob_session.session.config().capabilities
2297        );
2298        assert!(matches!(bob_session.target, SessionTarget::TcpStream(host) if host == target));
2299
2300        assert_eq!(vec![*alice_session.id()], alice_mgr.active_sessions());
2301        assert_eq!(None, alice_mgr.get_surb_balancer_config(alice_session.id())?);
2302        assert!(
2303            alice_mgr
2304                .update_surb_balancer_config(alice_session.id(), SurbBalancerConfig::default())
2305                .is_err()
2306        );
2307
2308        assert_eq!(vec![*bob_session.session.id()], bob_mgr.active_sessions());
2309        assert_eq!(None, bob_mgr.get_surb_balancer_config(bob_session.session.id())?);
2310        assert!(
2311            bob_mgr
2312                .update_surb_balancer_config(bob_session.session.id(), SurbBalancerConfig::default())
2313                .is_err()
2314        );
2315
2316        tokio::time::sleep(Duration::from_millis(100)).await;
2317        alice_session.close().await?;
2318
2319        tokio::time::sleep(Duration::from_millis(100)).await;
2320
2321        assert!(matches!(
2322            alice_mgr.ping_session(alice_session.id()).await,
2323            Err(TransportSessionError::Manager(SessionManagerError::NonExistingSession))
2324        ));
2325
2326        futures::stream::iter(ahs)
2327            .for_each(|ah| async move { ah.abort() })
2328            .await;
2329
2330        // Cleanup: close senders and await handles
2331        alice_sender.close_channel();
2332        bob_sender.close_channel();
2333        let _ = alice_handle.await;
2334        let _ = bob_handle.await;
2335
2336        Ok(())
2337    }
2338
2339    #[test_log::test(tokio::test)]
2340    async fn session_manager_should_close_idle_session_automatically() -> anyhow::Result<()> {
2341        let alice_pseudonym = HoprPseudonym::random();
2342        let bob_peer: Address = (&ChainKeypair::random()).into();
2343
2344        let cfg = SessionManagerConfig {
2345            idle_timeout: Duration::from_millis(200),
2346            ..Default::default()
2347        };
2348
2349        let alice_mgr = SessionManager::new(cfg);
2350        let bob_mgr = SessionManager::new(Default::default());
2351
2352        let mut sequence = mockall::Sequence::new();
2353        let mut alice_transport = MockMsgSender::new();
2354        let mut bob_transport = MockMsgSender::new();
2355
2356        // Alice sends the StartSession message
2357        let bob_mgr_clone = bob_mgr.clone();
2358        alice_transport
2359            .expect_send_message()
2360            .once()
2361            .in_sequence(&mut sequence)
2362            .withf(move |peer, data| {
2363                msg_type(data, StartProtocolDiscriminants::StartSession)
2364                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
2365            })
2366            .returning(move |_, data| {
2367                let bob_mgr_clone = bob_mgr_clone.clone();
2368                Box::pin(async move {
2369                    bob_mgr_clone
2370                        .dispatch_message(
2371                            alice_pseudonym,
2372                            ApplicationDataIn {
2373                                data: data.data,
2374                                packet_info: Default::default(),
2375                            },
2376                        )
2377                        ?;
2378                    Ok(())
2379                })
2380            });
2381
2382        // Bob sends the SessionEstablished message
2383        let alice_mgr_clone = alice_mgr.clone();
2384        bob_transport
2385            .expect_send_message()
2386            .once()
2387            .in_sequence(&mut sequence)
2388            .withf(move |peer, data| {
2389                msg_type(data, StartProtocolDiscriminants::SessionEstablished)
2390                    && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if p == &alice_pseudonym)
2391            })
2392            .returning(move |_, data| {
2393                let alice_mgr_clone = alice_mgr_clone.clone();
2394
2395                Box::pin(async move {
2396                    alice_mgr_clone.dispatch_message(
2397                        alice_pseudonym,
2398                        ApplicationDataIn {
2399                            data: data.data,
2400                            packet_info: Default::default(),
2401                        },
2402                    )?;
2403                    Ok(())
2404                })
2405            });
2406
2407        let mut ahs = Vec::new();
2408
2409        // Start Alice
2410        let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
2411        let (alice_sender, alice_handle) = mock_packet_planning(alice_transport);
2412        ahs.extend(alice_mgr.start(alice_sender.clone(), new_session_tx_alice)?);
2413
2414        // Start Bob
2415        let (new_session_tx_bob, new_session_rx_bob) = futures::channel::mpsc::channel(1024);
2416        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
2417        ahs.extend(bob_mgr.start(bob_sender.clone(), new_session_tx_bob)?);
2418        assert!(bob_mgr.is_started());
2419
2420        let target = SealedHost::Plain("127.0.0.1:80".parse()?);
2421
2422        pin_mut!(new_session_rx_bob);
2423        let (alice_session, bob_session) = timeout(
2424            Duration::from_secs(2),
2425            futures::future::join(
2426                alice_mgr.new_session(
2427                    bob_peer,
2428                    SessionTarget::TcpStream(target.clone()),
2429                    SessionClientConfig {
2430                        pseudonym: alice_pseudonym.into(),
2431                        capabilities: Capability::NoRateControl | Capability::Segmentation,
2432                        surb_management: None,
2433                        ..Default::default()
2434                    },
2435                ),
2436                new_session_rx_bob.next(),
2437            ),
2438        )
2439        .await?;
2440
2441        let alice_session = alice_session?;
2442        let bob_session = bob_session.ok_or(anyhow!("bob must get an incoming session"))?;
2443
2444        assert_eq!(
2445            alice_session.config().capabilities,
2446            Capability::Segmentation | Capability::NoRateControl,
2447        );
2448        assert_eq!(
2449            alice_session.config().capabilities,
2450            bob_session.session.config().capabilities
2451        );
2452        assert!(matches!(bob_session.target, SessionTarget::TcpStream(host) if host == target));
2453
2454        // Let the session timeout at Alice
2455        tokio::time::sleep(Duration::from_millis(300)).await;
2456
2457        assert!(matches!(
2458            alice_mgr.ping_session(alice_session.id()).await,
2459            Err(TransportSessionError::Manager(SessionManagerError::NonExistingSession))
2460        ));
2461
2462        futures::stream::iter(ahs)
2463            .for_each(|ah| async move { ah.abort() })
2464            .await;
2465
2466        // Cleanup: close senders and await handles
2467        alice_sender.close_channel();
2468        bob_sender.close_channel();
2469        let _ = alice_handle.await;
2470        let _ = bob_handle.await;
2471
2472        Ok(())
2473    }
2474
2475    #[test_log::test(tokio::test)]
2476    async fn session_manager_should_update_surb_balancer_config() -> anyhow::Result<()> {
2477        let alice_pseudonym = HoprPseudonym::random();
2478        let session_id = alice_pseudonym;
2479        let balancer_cfg = SurbBalancerConfig {
2480            target_surb_buffer_size: 1000,
2481            max_surbs_per_sec: 100,
2482            ..Default::default()
2483        };
2484
2485        let alice_mgr =
2486            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
2487
2488        let (dummy_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
2489        alice_mgr.sessions.insert(
2490            session_id,
2491            SessionSlot {
2492                session_tx: dummy_tx,
2493                routing_opts: DestinationRouting::Return(SurbMatcher::Pseudonym(alice_pseudonym)),
2494                abort_handles: Default::default(),
2495                surb_mgmt: Arc::new(BalancerStateValues::from(balancer_cfg)),
2496                surb_estimator: Default::default(),
2497            },
2498        );
2499
2500        let actual_cfg = alice_mgr
2501            .get_surb_balancer_config(&session_id)?
2502            .ok_or(anyhow!("session must have a surb balancer config"))?;
2503        assert_eq!(actual_cfg, balancer_cfg);
2504
2505        let new_cfg = SurbBalancerConfig {
2506            target_surb_buffer_size: 2000,
2507            max_surbs_per_sec: 200,
2508            ..Default::default()
2509        };
2510        alice_mgr.update_surb_balancer_config(&session_id, new_cfg)?;
2511
2512        let actual_cfg = alice_mgr
2513            .get_surb_balancer_config(&session_id)?
2514            .ok_or(anyhow!("session must have a surb balancer config"))?;
2515        assert_eq!(actual_cfg, new_cfg);
2516
2517        Ok(())
2518    }
2519
2520    #[test_log::test(tokio::test)]
2521    async fn session_manager_should_not_allow_loopback_sessions() -> anyhow::Result<()> {
2522        let alice_pseudonym = HoprPseudonym::random();
2523        let bob_peer: Address = (&ChainKeypair::random()).into();
2524
2525        let alice_mgr = SessionManager::new(Default::default());
2526
2527        let mut sequence = mockall::Sequence::new();
2528        let mut alice_transport = MockMsgSender::new();
2529
2530        // Alice sends the StartSession message
2531        let alice_mgr_clone = alice_mgr.clone();
2532        alice_transport
2533            .expect_send_message()
2534            .once()
2535            .in_sequence(&mut sequence)
2536            .withf(move |peer, data| {
2537                msg_type(data, StartProtocolDiscriminants::StartSession)
2538                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
2539            })
2540            .returning(move |_, data| {
2541                // But the message is again processed by Alice due to Loopback
2542                let alice_mgr_clone = alice_mgr_clone.clone();
2543                Box::pin(async move {
2544                    alice_mgr_clone
2545                        .dispatch_message(
2546                            alice_pseudonym,
2547                            ApplicationDataIn {
2548                                data: data.data,
2549                                packet_info: Default::default(),
2550                            },
2551                        )
2552                        ?;
2553                    Ok(())
2554                })
2555            });
2556
2557        // Alice sends the SessionEstablished message (as Bob)
2558        let alice_mgr_clone = alice_mgr.clone();
2559        alice_transport
2560            .expect_send_message()
2561            .once()
2562            .in_sequence(&mut sequence)
2563            .withf(move |peer, data| {
2564                msg_type(data, StartProtocolDiscriminants::SessionEstablished)
2565                    && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if p == &alice_pseudonym)
2566            })
2567            .returning(move |_, data| {
2568                let alice_mgr_clone = alice_mgr_clone.clone();
2569
2570                Box::pin(async move {
2571                    alice_mgr_clone.dispatch_message(
2572                        alice_pseudonym,
2573                        ApplicationDataIn {
2574                            data: data.data,
2575                            packet_info: Default::default(),
2576                        },
2577                    )?;
2578                    Ok(())
2579                })
2580            });
2581
2582        // Start Alice
2583        let (new_session_tx_alice, new_session_rx_alice) = futures::channel::mpsc::channel(1024);
2584        let (alice_sender, alice_handle) = mock_packet_planning(alice_transport);
2585        alice_mgr.start(alice_sender.clone(), new_session_tx_alice)?;
2586        assert!(alice_mgr.is_started());
2587
2588        let alice_session = alice_mgr
2589            .new_session(
2590                bob_peer,
2591                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
2592                SessionClientConfig {
2593                    capabilities: None.into(),
2594                    pseudonym: alice_pseudonym.into(),
2595                    surb_management: None,
2596                    ..Default::default()
2597                },
2598            )
2599            .await;
2600
2601        println!("{alice_session:?}");
2602        assert!(matches!(
2603            alice_session,
2604            Err(TransportSessionError::Manager(SessionManagerError::Loopback))
2605        ));
2606
2607        drop(new_session_rx_alice);
2608
2609        // Cleanup: close sender and await handle
2610        alice_sender.close_channel();
2611        let _ = alice_handle.await;
2612
2613        Ok(())
2614    }
2615
2616    #[test_log::test(tokio::test)]
2617    async fn session_manager_should_timeout_new_session_attempt_when_no_response() -> anyhow::Result<()> {
2618        let bob_peer: Address = (&ChainKeypair::random()).into();
2619
2620        let cfg = SessionManagerConfig {
2621            initiation_timeout_base: Duration::from_millis(100),
2622            ..Default::default()
2623        };
2624
2625        let alice_mgr = SessionManager::new(cfg);
2626        let bob_mgr = SessionManager::new(Default::default());
2627
2628        let mut sequence = mockall::Sequence::new();
2629        let mut alice_transport = MockMsgSender::new();
2630        let bob_transport = MockMsgSender::new();
2631
2632        // Alice sends the StartSession message, but Bob does not handle it
2633        alice_transport
2634            .expect_send_message()
2635            .once()
2636            .in_sequence(&mut sequence)
2637            .withf(move |peer, data| {
2638                msg_type(data, StartProtocolDiscriminants::StartSession)
2639                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
2640            })
2641            .returning(|_, _| Box::pin(async { Ok(()) }));
2642
2643        // Start Alice
2644        let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
2645        let (alice_sender, _alice_handle) = mock_packet_planning(alice_transport);
2646        alice_mgr.start(alice_sender.clone(), new_session_tx_alice)?;
2647        assert!(alice_mgr.is_started());
2648
2649        // Start Bob
2650        let (new_session_tx_bob, _) = futures::channel::mpsc::channel(1024);
2651        let (bob_sender, _bob_handle) = mock_packet_planning(bob_transport);
2652        bob_mgr.start(bob_sender.clone(), new_session_tx_bob)?;
2653        assert!(bob_mgr.is_started());
2654
2655        let result = alice_mgr
2656            .new_session(
2657                bob_peer,
2658                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
2659                SessionClientConfig {
2660                    capabilities: None.into(),
2661                    pseudonym: None,
2662                    surb_management: None,
2663                    ..Default::default()
2664                },
2665            )
2666            .await;
2667
2668        assert!(matches!(result, Err(TransportSessionError::Timeout)));
2669
2670        Ok(())
2671    }
2672
2673    #[cfg(feature = "telemetry")]
2674    #[test_log::test(tokio::test)]
2675    async fn failed_incoming_session_establishment_does_not_register_telemetry() -> anyhow::Result<()> {
2676        let mgr = SessionManager::new(Default::default());
2677
2678        let transport = MockMsgSender::new();
2679        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
2680        drop(new_session_rx);
2681        let (sender, _handle) = mock_packet_planning(transport);
2682        mgr.start(sender.clone(), new_session_tx)?;
2683        assert!(mgr.is_started());
2684
2685        let pseudonym = HoprPseudonym::random();
2686        let result = mgr
2687            .handle_incoming_session_initiation(
2688                pseudonym,
2689                StartInitiation {
2690                    challenge: MIN_CHALLENGE,
2691                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
2692                    capabilities: ByteCapabilities(Capabilities::empty()),
2693                    additional_data: 0,
2694                },
2695            )
2696            .await;
2697
2698        assert!(result.is_err());
2699
2700        // The slot inserted before the failure must be rolled back, so it neither
2701        // counts towards `maximum_sessions` nor registers any telemetry.
2702        assert!(
2703            wait_for_no_active_sessions(&mgr).await,
2704            "the partially established session slot was not rolled back"
2705        );
2706
2707        // Cleanup: close sender and await handle
2708        sender.close_channel();
2709        let _ = _handle.await;
2710
2711        Ok(())
2712    }
2713
2714    #[test_log::test(tokio::test)]
2715    async fn session_manager_should_roll_back_slot_when_incoming_session_setup_fails() -> anyhow::Result<()> {
2716        let mgr = SessionManager::new(Default::default());
2717
2718        // Drop the receiver so that notifying about the new incoming session fails
2719        // *after* the session slot has already been inserted into the cache.
2720        let transport = MockMsgSender::new();
2721        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
2722        drop(new_session_rx);
2723        let (sender, handle) = mock_packet_planning(transport);
2724        mgr.start(sender.clone(), new_session_tx)?;
2725        assert!(mgr.is_started());
2726
2727        let pseudonym = HoprPseudonym::random();
2728
2729        // The setup fails after the slot is inserted (notifying about the new
2730        // incoming session errors out because the receiver is gone), so the slot
2731        // must be rolled back instead of lingering until idle eviction.
2732        let result = mgr
2733            .handle_incoming_session_initiation(
2734                pseudonym,
2735                StartInitiation {
2736                    challenge: MIN_CHALLENGE,
2737                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
2738                    capabilities: ByteCapabilities(Capabilities::empty()),
2739                    additional_data: 0,
2740                },
2741            )
2742            .await;
2743        assert!(result.is_err());
2744
2745        // An empty active-session set proves the slot was removed and, since
2746        // sessions are keyed by pseudonym, that the pseudonym is free again.
2747        assert!(
2748            wait_for_no_active_sessions(&mgr).await,
2749            "the partially established session slot was not rolled back"
2750        );
2751
2752        // Cleanup
2753        sender.close_channel();
2754        let _ = handle.await;
2755
2756        Ok(())
2757    }
2758
2759    /// Collects everything that arrives on `rx` during `window`, returning once it elapses.
2760    async fn originated_during(
2761        rx: &mut futures::channel::mpsc::UnboundedReceiver<(DestinationRouting, ApplicationDataOut)>,
2762        window: Duration,
2763    ) -> Vec<(DestinationRouting, ApplicationDataOut)> {
2764        let mut collected = Vec::new();
2765        let deadline = tokio::time::Instant::now() + window;
2766        while let Ok(Some(item)) = timeout(
2767            deadline.saturating_duration_since(tokio::time::Instant::now()),
2768            rx.next(),
2769        )
2770        .await
2771        {
2772            collected.push(item);
2773        }
2774        collected
2775    }
2776
2777    /// The manager floors the keep-alive period at [`MIN_SURB_BUFFER_NOTIFICATION_PERIOD`], so this
2778    /// is as fast as an Exit keep-alive can be made to run, and it sets the pace of these tests.
2779    const KEEP_ALIVE_PERIOD: Duration = MIN_SURB_BUFFER_NOTIFICATION_PERIOD;
2780
2781    type RecordingManager = SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>;
2782    type Originated = futures::channel::mpsc::UnboundedReceiver<(DestinationRouting, ApplicationDataOut)>;
2783
2784    /// Brings up an Exit-side session and returns once its keep-alive stream is observably running.
2785    ///
2786    /// The "observably running" part is load-bearing for every caller: `nothing was originated` is
2787    /// equally true of a stream that stopped and one that never started, so a test that does not
2788    /// first establish the stream is alive proves nothing when it later sees silence.
2789    async fn exit_session_originating_keep_alives(
2790        cfg: SessionManagerConfig,
2791    ) -> anyhow::Result<(RecordingManager, Originated, HoprPseudonym)> {
2792        let mgr = RecordingManager::new(SessionManagerConfig {
2793            surb_balance_notify_period: Some(KEEP_ALIVE_PERIOD),
2794            ..cfg
2795        });
2796
2797        let (msg_tx, mut msg_rx) = futures::channel::mpsc::unbounded();
2798        // Held, not dropped: dropping it would fail the establishment notification instead.
2799        let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(4);
2800        mgr.start(msg_tx, new_session_tx)?;
2801
2802        let pseudonym = HoprPseudonym::random();
2803        mgr.handle_incoming_session_initiation(
2804            pseudonym,
2805            StartInitiation {
2806                challenge: MIN_CHALLENGE,
2807                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
2808                // Empty capabilities keep rate control on, which is what spawns the balancer and
2809                // the keep-alive stream. `NoRateControl` would skip both and make this vacuous.
2810                capabilities: ByteCapabilities(Capabilities::empty()),
2811                additional_data: 0,
2812            },
2813        )
2814        .await?;
2815
2816        let observed = originated_during(&mut msg_rx, KEEP_ALIVE_PERIOD * 2 + Duration::from_millis(500)).await;
2817        let keep_alives = observed
2818            .iter()
2819            .filter(|(routing, data)| {
2820                msg_type(data, StartProtocolDiscriminants::KeepAlive)
2821                    && matches!(routing, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if p == &pseudonym)
2822            })
2823            .count();
2824        anyhow::ensure!(
2825            keep_alives > 0,
2826            "no return-routed keep-alive was originated, so this test cannot tell a stopped stream from one that \
2827             never ran; {} message(s) were observed in total",
2828            observed.len()
2829        );
2830
2831        Ok((mgr, msg_rx, pseudonym))
2832    }
2833
2834    /// Asserts `mgr` originates nothing further for `pseudonym`.
2835    ///
2836    /// Packets already handed to the sender before the closure are in flight rather than newly
2837    /// originated, so they are drained first and only what appears afterwards counts.
2838    async fn assert_no_further_origination(rx: &mut Originated, closure: &str) {
2839        let _in_flight = originated_during(rx, Duration::from_millis(200)).await;
2840
2841        let window = KEEP_ALIVE_PERIOD * 3;
2842        let after = originated_during(rx, window).await;
2843        assert!(
2844            after.is_empty(),
2845            "an Exit session closed by {closure} originated {} further packet(s) over {window:?} — each one is \
2846             return-routed to a pseudonym whose SURBs are gone, and one such packet is enough to stall all \
2847             origination on the node",
2848            after.len()
2849        );
2850    }
2851
2852    /// An Exit session must originate nothing once it has been closed explicitly.
2853    ///
2854    /// The Exit's keep-alive stream is a `repeat_with` on a rate limiter: it produces a
2855    /// return-routed packet every period regardless of whether a SURB exists to carry it. That is
2856    /// fine while the initiator is present and replenishing, and it is the *supply* side of the
2857    /// `london-01` outage once the initiator is gone — every such packet is one the routing
2858    /// resolution stage can never resolve, and one unresolvable packet there stalls all origination
2859    /// on the node (see `hopr_transport::path::resolve`).
2860    ///
2861    /// Teardown is therefore the only thing standing between a departed initiator and an unbounded
2862    /// supply of unresolvable packets, which is why each closure path has to be shown to stop the
2863    /// stream rather than merely drop the slot.
2864    #[test_log::test(tokio::test)]
2865    async fn an_exit_session_should_originate_nothing_after_an_explicit_close() -> anyhow::Result<()> {
2866        // Default idle timeout (180 s), so eviction cannot confound what the explicit close proves.
2867        let (mgr, mut msg_rx, pseudonym) = exit_session_originating_keep_alives(Default::default()).await?;
2868
2869        assert!(mgr.close_session(&pseudonym), "the session must exist to be closed");
2870
2871        assert_no_further_origination(&mut msg_rx, "an explicit close").await;
2872        Ok(())
2873    }
2874
2875    /// An Exit session must originate nothing once it has been evicted for being idle.
2876    ///
2877    /// This is the path that matters most for a departed initiator: nobody closes that session, so
2878    /// idle eviction is what ends it, and eviction runs through a Moka listener rather than the
2879    /// explicit close path. A keep-alive stream that survives eviction would go on originating
2880    /// unresolvable return packets with no session left to account for them.
2881    #[test_log::test(tokio::test)]
2882    async fn an_exit_session_should_originate_nothing_after_idle_eviction() -> anyhow::Result<()> {
2883        let idle_timeout = KEEP_ALIVE_PERIOD * 3;
2884        let (mgr, mut msg_rx, _) = exit_session_originating_keep_alives(SessionManagerConfig {
2885            idle_timeout,
2886            ..Default::default()
2887        })
2888        .await?;
2889
2890        // Moka evicts lazily, so drive its maintenance rather than waiting for the manager's own
2891        // (jittered, multi-second) eviction tick: this makes the eviction prompt and deterministic.
2892        for _ in 0..50 {
2893            mgr.sessions.run_pending_tasks();
2894            if mgr.active_sessions().is_empty() {
2895                break;
2896            }
2897            tokio::time::sleep(idle_timeout / 10).await;
2898        }
2899        assert!(
2900            mgr.active_sessions().is_empty(),
2901            "the idle session was never evicted, so this test cannot say anything about eviction"
2902        );
2903
2904        assert_no_further_origination(&mut msg_rx, "idle eviction").await;
2905        Ok(())
2906    }
2907
2908    #[test_log::test(tokio::test)]
2909    async fn session_manager_should_send_keep_alives_via_surb_balancer() -> anyhow::Result<()> {
2910        let alice_pseudonym = HoprPseudonym::random();
2911        let bob_peer: Address = (&ChainKeypair::random()).into();
2912
2913        let bob_cfg = SessionManagerConfig {
2914            surb_balance_notify_period: Some(Duration::from_millis(500)),
2915            ..Default::default()
2916        };
2917        let alice_mgr = SessionManager::new(Default::default());
2918        let bob_mgr = SessionManager::new(bob_cfg.clone());
2919
2920        let mut alice_transport = MockMsgSender::new();
2921        let mut bob_transport = MockMsgSender::new();
2922
2923        // Alice sends the StartSession message
2924        let mut open_sequence = mockall::Sequence::new();
2925        let bob_mgr_clone = bob_mgr.clone();
2926        alice_transport
2927            .expect_send_message()
2928            .once()
2929            .in_sequence(&mut open_sequence)
2930            .withf(move |peer, data| {
2931                msg_type(data, StartProtocolDiscriminants::StartSession)
2932                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
2933            })
2934            .returning(move |_, data| {
2935                let bob_mgr_clone = bob_mgr_clone.clone();
2936                Box::pin(async move {
2937                    bob_mgr_clone
2938                        .dispatch_message(
2939                            alice_pseudonym,
2940                            ApplicationDataIn {
2941                                data: data.data,
2942                                packet_info: Default::default(),
2943                            },
2944                        )
2945                        ?;
2946                    Ok(())
2947                })
2948            });
2949
2950        // Bob sends the SessionEstablished message
2951        let alice_mgr_clone = alice_mgr.clone();
2952        bob_transport
2953            .expect_send_message()
2954            .once()
2955            .in_sequence(&mut open_sequence)
2956            .withf(move |peer, data| {
2957                msg_type(data, StartProtocolDiscriminants::SessionEstablished)
2958                    && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if p == &alice_pseudonym)
2959            })
2960            .returning(move |_, data| {
2961                let alice_mgr_clone = alice_mgr_clone.clone();
2962                Box::pin(async move {
2963                    alice_mgr_clone.dispatch_message(
2964                        alice_pseudonym,
2965                        ApplicationDataIn {
2966                            data: data.data,
2967                            packet_info: Default::default(),
2968                        },
2969                    )?;
2970                    Ok(())
2971                })
2972            });
2973
2974        const INITIAL_BALANCER_TARGET: u64 = 10;
2975
2976        // Alice sends the KeepAlive messages reporting the initial balancer target
2977        let bob_mgr_clone = bob_mgr.clone();
2978        alice_transport
2979            .expect_send_message()
2980            .times(5..)
2981            //.in_sequence(&mut sequence)
2982            .withf(move |peer, data| {
2983                start_msg_match(data, |msg| matches!(msg, StartProtocol::KeepAlive(ka) if ka.flags.contains(KeepAliveFlag::BalancerTarget) && ka.additional_data == INITIAL_BALANCER_TARGET))
2984                //msg_type(data, StartProtocolDiscriminants::KeepAlive)
2985                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
2986            })
2987            .returning(move |_, data| {
2988                let bob_mgr_clone = bob_mgr_clone.clone();
2989                Box::pin(async move {
2990                    bob_mgr_clone
2991                        .dispatch_message(
2992                            alice_pseudonym,
2993                            ApplicationDataIn {
2994                                data: data.data,
2995                                packet_info: Default::default(),
2996                            },
2997                        )
2998                        ?;
2999                    Ok(())
3000                })
3001            });
3002
3003        const NEXT_BALANCER_TARGET: u64 = 50;
3004
3005        // Alice sends also the KeepAlive messages reporting the updated balancer target
3006        let bob_mgr_clone = bob_mgr.clone();
3007        alice_transport
3008            .expect_send_message()
3009            .times(5..)
3010            //.in_sequence(&mut sequence)
3011            .withf(move |peer, data| {
3012                start_msg_match(data, |msg| matches!(msg, StartProtocol::KeepAlive(ka) if ka.flags.contains(KeepAliveFlag::BalancerTarget) && ka.additional_data == NEXT_BALANCER_TARGET))
3013                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
3014            })
3015            .returning(move |_, data| {
3016                let bob_mgr_clone = bob_mgr_clone.clone();
3017                Box::pin(async move {
3018                    bob_mgr_clone
3019                        .dispatch_message(
3020                            alice_pseudonym,
3021                            ApplicationDataIn {
3022                                data: data.data,
3023                                packet_info: Default::default(),
3024                            },
3025                        )
3026                        ?;
3027                    Ok(())
3028                })
3029            });
3030
3031        // Bob sends at least 1 Keep Alive back reporting its SURB estimate
3032        let alice_mgr_clone = alice_mgr.clone();
3033        bob_transport
3034            .expect_send_message()
3035            .times(1..)
3036            //.in_sequence(&mut open_sequence)
3037            .withf(move |peer, data| {
3038                start_msg_match(data, |msg| matches!(msg, StartProtocol::KeepAlive(ka) if ka.flags.contains(KeepAliveFlag::BalancerState) && ka.additional_data > 0))
3039                    && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if p == &alice_pseudonym)
3040            })
3041            .returning(move |_, data| {
3042                let alice_mgr_clone = alice_mgr_clone.clone();
3043                Box::pin(async move {
3044                    alice_mgr_clone
3045                        .dispatch_message(
3046                            alice_pseudonym,
3047                            ApplicationDataIn {
3048                                data: data.data,
3049                                packet_info: Default::default(),
3050                            },
3051                        )
3052                        ?;
3053                    Ok(())
3054                })
3055            });
3056
3057        // Alice sends the terminating segment to close the Session
3058        let bob_mgr_clone = bob_mgr.clone();
3059        alice_transport
3060            .expect_send_message()
3061            .once()
3062            //.in_sequence(&mut sequence)
3063            .withf(move |peer, data| {
3064                hopr_protocol_session::types::SessionMessage::<{ ApplicationData::PAYLOAD_SIZE }>::try_from(
3065                    data.data.plain_text.as_ref(),
3066                )
3067                .ok()
3068                .and_then(|m| m.try_as_segment())
3069                .map(|s| s.is_terminating())
3070                .unwrap_or(false)
3071                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
3072            })
3073            .returning(move |_, data| {
3074                let bob_mgr_clone = bob_mgr_clone.clone();
3075                Box::pin(async move {
3076                    bob_mgr_clone
3077                        .dispatch_message(
3078                            alice_pseudonym,
3079                            ApplicationDataIn {
3080                                data: data.data,
3081                                packet_info: Default::default(),
3082                            },
3083                        )
3084                        ?;
3085                    Ok(())
3086                })
3087            });
3088
3089        let mut ahs = Vec::new();
3090
3091        // Start Alice
3092        let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
3093        let (alice_sender, alice_handle) = mock_packet_planning(alice_transport);
3094        ahs.extend(alice_mgr.start(alice_sender.clone(), new_session_tx_alice)?);
3095        assert!(alice_mgr.is_started());
3096
3097        // Start Bob
3098        let (new_session_tx_bob, new_session_rx_bob) = futures::channel::mpsc::channel(1024);
3099        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
3100        ahs.extend(bob_mgr.start(bob_sender.clone(), new_session_tx_bob)?);
3101        assert!(bob_mgr.is_started());
3102
3103        let target = SealedHost::Plain("127.0.0.1:80".parse()?);
3104
3105        let balancer_cfg = SurbBalancerConfig {
3106            target_surb_buffer_size: INITIAL_BALANCER_TARGET,
3107            max_surbs_per_sec: 100,
3108            ..Default::default()
3109        };
3110
3111        pin_mut!(new_session_rx_bob);
3112        let (alice_session, bob_session) = timeout(
3113            Duration::from_secs(2),
3114            futures::future::join(
3115                alice_mgr.new_session(
3116                    bob_peer,
3117                    SessionTarget::TcpStream(target.clone()),
3118                    SessionClientConfig {
3119                        pseudonym: alice_pseudonym.into(),
3120                        capabilities: Capability::Segmentation.into(),
3121                        surb_management: Some(balancer_cfg),
3122                        ..Default::default()
3123                    },
3124                ),
3125                new_session_rx_bob.next(),
3126            ),
3127        )
3128        .await?;
3129
3130        let mut alice_session = alice_session?;
3131        let bob_session = bob_session.ok_or(anyhow!("bob must get an incoming session"))?;
3132
3133        assert!(matches!(bob_session.target, SessionTarget::TcpStream(host) if host == target));
3134
3135        assert_eq!(
3136            Some(balancer_cfg),
3137            alice_mgr.get_surb_balancer_config(alice_session.id())?
3138        );
3139
3140        let remote_cfg = bob_mgr
3141            .get_surb_balancer_config(bob_session.session.id())?
3142            .ok_or(anyhow!("no remote config at bob"))?;
3143        assert_eq!(remote_cfg.target_surb_buffer_size, balancer_cfg.target_surb_buffer_size);
3144        assert_eq!(
3145            remote_cfg.max_surbs_per_sec,
3146            remote_cfg.target_surb_buffer_size
3147                / bob_cfg
3148                    .minimum_surb_buffer_duration
3149                    .max(MIN_SURB_BUFFER_DURATION)
3150                    .as_secs()
3151        );
3152
3153        // Let the Surb balancer send enough KeepAlive messages
3154        tokio::time::sleep(Duration::from_millis(1500)).await;
3155
3156        let new_balancer_cfg = SurbBalancerConfig {
3157            target_surb_buffer_size: NEXT_BALANCER_TARGET,
3158            max_surbs_per_sec: 100,
3159            ..Default::default()
3160        };
3161
3162        // Update to a higher target
3163        alice_mgr.update_surb_balancer_config(alice_session.id(), new_balancer_cfg)?;
3164
3165        // Let the Surb balancer send enough KeepAlive messages
3166        tokio::time::sleep(Duration::from_millis(1500)).await;
3167
3168        // Bob should know about the updated target
3169        let remote_cfg = bob_mgr
3170            .get_surb_balancer_config(bob_session.session.id())?
3171            .ok_or(anyhow!("no remote config at bob"))?;
3172        assert_eq!(
3173            remote_cfg.target_surb_buffer_size,
3174            new_balancer_cfg.target_surb_buffer_size
3175        );
3176        assert_eq!(
3177            remote_cfg.max_surbs_per_sec,
3178            new_balancer_cfg.target_surb_buffer_size / bob_cfg.minimum_surb_buffer_duration.as_secs()
3179        );
3180
3181        let (alice_surb_sent, alice_surb_used) = alice_mgr.get_surb_level_estimates(alice_session.id())?;
3182        let (bob_surb_recv, bob_surb_used) = bob_mgr.get_surb_level_estimates(bob_session.session.id())?;
3183
3184        alice_session.close().await?;
3185
3186        assert!(alice_surb_sent > 0, "alice must've sent surbs");
3187        assert!(bob_surb_recv > 0, "bob must've received surbs");
3188        assert!(
3189            bob_surb_recv <= alice_surb_sent,
3190            "bob cannot receive more surbs than alice sent"
3191        );
3192
3193        assert!(alice_surb_used > 0, "alice must see bob used surbs");
3194        assert!(bob_surb_used > 0, "bob must've used surbs");
3195        assert!(
3196            alice_surb_used <= bob_surb_used,
3197            "alice cannot see bob used more surbs than bob actually used"
3198        );
3199
3200        tokio::time::sleep(Duration::from_millis(300)).await;
3201        assert!(matches!(
3202            alice_mgr.ping_session(alice_session.id()).await,
3203            Err(TransportSessionError::Manager(SessionManagerError::NonExistingSession))
3204        ));
3205
3206        futures::stream::iter(ahs)
3207            .for_each(|ah| async move { ah.abort() })
3208            .await;
3209
3210        // Cleanup: close senders and await handles
3211        alice_sender.close_channel();
3212        bob_sender.close_channel();
3213        let _ = alice_handle.await;
3214        let _ = bob_handle.await;
3215
3216        Ok(())
3217    }
3218
3219    #[test_log::test(tokio::test)]
3220    async fn session_manager_should_supersede_stale_session_on_reinitiation_with_same_pseudonym() -> anyhow::Result<()>
3221    {
3222        use hopr_utils::network_types::prelude::SealedHost;
3223
3224        let bob_mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
3225            SessionManager::new(Default::default());
3226
3227        // Start the manager (required for handling incoming sessions)
3228        let mut transport = MockMsgSender::new();
3229        transport
3230            .expect_send_message()
3231            .times(2)
3232            .returning(|_, _| futures::future::ok(()).boxed());
3233
3234        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
3235        // Spawn a task to receive new session notifications
3236        let _notifications = tokio::spawn(async move {
3237            pin_mut!(new_session_rx);
3238            while let Some(_session) = new_session_rx.next().await {
3239                // Just drain the channel
3240            }
3241        });
3242        let (sender, _handle) = mock_packet_planning(transport);
3243        bob_mgr.start(sender.clone(), new_session_tx)?;
3244        assert!(bob_mgr.is_started());
3245
3246        let pseudonym = HoprPseudonym::random();
3247
3248        // First session initiation - should succeed
3249        let result = bob_mgr
3250            .handle_incoming_session_initiation(
3251                pseudonym,
3252                StartInitiation {
3253                    challenge: MIN_CHALLENGE,
3254                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
3255                    capabilities: ByteCapabilities(Capabilities::empty()),
3256                    additional_data: 0,
3257                },
3258            )
3259            .await;
3260
3261        assert!(result.is_ok(), "first session initiation should succeed");
3262
3263        // Verify one session exists
3264        let active = bob_mgr.active_sessions();
3265        assert_eq!(active.len(), 1, "should have exactly one active session");
3266
3267        // Second session initiation with the same pseudonym: the stale session is
3268        // closed and the new initiation takes the slot over (a re-initiation means
3269        // the initiator has lost or abandoned its side of the old session).
3270        let result = bob_mgr
3271            .handle_incoming_session_initiation(
3272                pseudonym,
3273                StartInitiation {
3274                    challenge: MIN_CHALLENGE + 1,
3275                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
3276                    capabilities: ByteCapabilities(Capabilities::empty()),
3277                    additional_data: 0,
3278                },
3279            )
3280            .await;
3281
3282        assert!(result.is_ok(), "re-initiation should supersede the stale session");
3283
3284        // The stale session must have been replaced, not duplicated
3285        let active = bob_mgr.active_sessions();
3286        assert_eq!(active.len(), 1, "should still have exactly one active session");
3287
3288        // Cleanup: close sender and await handle
3289        sender.close_channel();
3290        let _ = _handle.await;
3291
3292        Ok(())
3293    }
3294
3295    #[test_log::test(tokio::test)]
3296    async fn session_manager_should_return_error_when_pinging_non_existent_session() -> anyhow::Result<()> {
3297        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
3298            SessionManager::new(Default::default());
3299
3300        let transport = MockMsgSender::new();
3301        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
3302        let _notifications = tokio::spawn(async move {
3303            pin_mut!(new_session_rx);
3304            while let Some(_session) = new_session_rx.next().await {}
3305        });
3306        let (sender, _handle) = mock_packet_planning(transport);
3307        mgr.start(sender.clone(), new_session_tx)?;
3308        assert!(mgr.is_started());
3309
3310        let fake_session_id = HoprPseudonym::random();
3311        let result = mgr.ping_session(&fake_session_id).await;
3312
3313        assert!(result.is_err());
3314        assert!(matches!(
3315            result.unwrap_err(),
3316            TransportSessionError::Manager(SessionManagerError::NonExistingSession)
3317        ));
3318
3319        // Cleanup: close sender and await handle
3320        sender.close_channel();
3321        let _ = _handle.await;
3322
3323        Ok(())
3324    }
3325
3326    #[test_log::test(tokio::test)]
3327    async fn session_manager_should_return_false_when_closing_non_existent_session() -> anyhow::Result<()> {
3328        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
3329            SessionManager::new(Default::default());
3330
3331        let transport = MockMsgSender::new();
3332        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
3333        let _notifications = tokio::spawn(async move {
3334            pin_mut!(new_session_rx);
3335            while let Some(_session) = new_session_rx.next().await {}
3336        });
3337        let (sender, _handle) = mock_packet_planning(transport);
3338        mgr.start(sender.clone(), new_session_tx)?;
3339        assert!(mgr.is_started());
3340
3341        let fake_session_id = HoprPseudonym::random();
3342        let result = mgr.close_session(&fake_session_id);
3343
3344        assert!(!result, "closing non-existent session should return false");
3345
3346        Ok(())
3347    }
3348
3349    #[test_log::test(tokio::test)]
3350    async fn session_manager_should_return_error_when_updating_surb_config_for_non_existent_session()
3351    -> anyhow::Result<()> {
3352        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
3353            SessionManager::new(Default::default());
3354
3355        let transport = MockMsgSender::new();
3356        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
3357        let _notifications = tokio::spawn(async move {
3358            pin_mut!(new_session_rx);
3359            while let Some(_session) = new_session_rx.next().await {}
3360        });
3361        let (sender, _handle) = mock_packet_planning(transport);
3362        mgr.start(sender.clone(), new_session_tx)?;
3363        assert!(mgr.is_started());
3364
3365        let fake_session_id = HoprPseudonym::random();
3366        let result = mgr.update_surb_balancer_config(&fake_session_id, SurbBalancerConfig::default());
3367
3368        assert!(result.is_err());
3369
3370        // Cleanup: close sender and await handle
3371        sender.close_channel();
3372        let _ = _handle.await;
3373
3374        Ok(())
3375    }
3376
3377    #[test_log::test(tokio::test)]
3378    async fn session_manager_should_return_error_when_getting_surb_config_for_non_existent_session()
3379    -> anyhow::Result<()> {
3380        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
3381            SessionManager::new(Default::default());
3382
3383        let transport = MockMsgSender::new();
3384        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
3385        let _notifications = tokio::spawn(async move {
3386            pin_mut!(new_session_rx);
3387            while let Some(_session) = new_session_rx.next().await {}
3388        });
3389        let (sender, _handle) = mock_packet_planning(transport);
3390        mgr.start(sender.clone(), new_session_tx)?;
3391        assert!(mgr.is_started());
3392
3393        let fake_session_id = HoprPseudonym::random();
3394        let result = mgr.get_surb_balancer_config(&fake_session_id);
3395
3396        assert!(result.is_err());
3397        assert!(matches!(
3398            result.unwrap_err(),
3399            TransportSessionError::Manager(SessionManagerError::NonExistingSession)
3400        ));
3401
3402        // Cleanup: close sender and await handle
3403        sender.close_channel();
3404        let _ = _handle.await;
3405
3406        Ok(())
3407    }
3408
3409    #[test_log::test(tokio::test)]
3410    async fn session_manager_should_return_error_when_getting_surb_estimates_for_non_existent_session()
3411    -> anyhow::Result<()> {
3412        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
3413            SessionManager::new(Default::default());
3414
3415        let transport = MockMsgSender::new();
3416        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
3417        let _notifications = tokio::spawn(async move {
3418            pin_mut!(new_session_rx);
3419            while let Some(_session) = new_session_rx.next().await {}
3420        });
3421        let (sender, _handle) = mock_packet_planning(transport);
3422        mgr.start(sender.clone(), new_session_tx)?;
3423        assert!(mgr.is_started());
3424
3425        let fake_session_id = HoprPseudonym::random();
3426        let result = mgr.get_surb_level_estimates(&fake_session_id);
3427
3428        assert!(result.is_err());
3429        assert!(matches!(
3430            result.unwrap_err(),
3431            TransportSessionError::Manager(SessionManagerError::NonExistingSession)
3432        ));
3433
3434        // Cleanup: close sender and await handle
3435        sender.close_channel();
3436        let _ = _handle.await;
3437
3438        Ok(())
3439    }
3440
3441    /// Verifies the `HoprStartProtocol::SessionError` match arm (line 689) in the
3442    /// `session_start_protocol_processor` task by calling `handle_session_error` directly.
3443    ///
3444    /// When a `SessionError` message is delivered while a `new_session` call is awaiting,
3445    /// `handle_session_error` retrieves the pending challenge from `session_initiations`,
3446    /// sends the error down the channel, and `new_session` propagates it as `Rejected`.
3447    #[test_log::test(tokio::test)]
3448    async fn handle_session_error_propagates_peer_rejection_to_pending_new_session() -> anyhow::Result<()> {
3449        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
3450            SessionManager::new(Default::default());
3451
3452        let mut transport = MockMsgSender::new();
3453        // new_session sends StartSession (succeeds), then waits for SessionEstablished.
3454        // We inject the error before it arrives.
3455        transport
3456            .expect_send_message()
3457            .returning(|_, _| futures::future::ok(()).boxed());
3458
3459        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
3460        let _notifications = tokio::spawn(async move {
3461            pin_mut!(new_session_rx);
3462            while let Some(_session) = new_session_rx.next().await {}
3463        });
3464        let (sender, _handle) = mock_packet_planning(transport);
3465        mgr.start(sender.clone(), new_session_tx)?;
3466        assert!(mgr.is_started());
3467
3468        // Spawn new_session so it is blocked waiting for the session establishment response.
3469        let mgr_clone = mgr.clone();
3470        let peer_address: Address = (&ChainKeypair::random()).into();
3471        let handle = tokio::spawn(async move {
3472            mgr_clone
3473                .new_session(
3474                    peer_address,
3475                    SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
3476                    SessionClientConfig {
3477                        surb_management: None,
3478                        ..Default::default()
3479                    },
3480                )
3481                .await
3482        });
3483
3484        // Give new_session time to insert the challenge into session_initiations.
3485        let challenge = tokio::time::timeout(Duration::from_secs(1), async {
3486            loop {
3487                if let Some((ch, _)) = mgr.session_initiations.iter().next() {
3488                    break *ch;
3489                }
3490                tokio::time::sleep(Duration::from_millis(10)).await;
3491            }
3492        })
3493        .await
3494        .context("new_session did not insert a challenge into session_initiations")?;
3495
3496        // Inject the SessionError with the matching challenge before SessionEstablished arrives.
3497        let error_type = StartErrorType {
3498            challenge,
3499            reason: StartErrorReason::NoSlotsAvailable,
3500        };
3501        mgr.handle_session_error(error_type).await?;
3502
3503        // new_session must propagate the error as Rejected.
3504        let result = handle.await?;
3505        match result {
3506            Ok(_session) => panic!("expected rejection error, got session"),
3507            Err(e) => {
3508                assert!(matches!(
3509                    e,
3510                    TransportSessionError::Rejected(StartErrorReason::NoSlotsAvailable)
3511                ));
3512            }
3513        }
3514
3515        sender.close_channel();
3516        let _ = _handle.await;
3517        Ok(())
3518    }
3519
3520    #[test_log::test(tokio::test)]
3521    async fn session_manager_should_reject_new_session_when_max_sessions_reached() -> anyhow::Result<()> {
3522        use hopr_utils::network_types::prelude::SealedHost;
3523
3524        // Create manager with max 1 session
3525        let cfg = SessionManagerConfig {
3526            maximum_sessions: 1,
3527            ..Default::default()
3528        };
3529        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
3530            SessionManager::new(cfg);
3531
3532        let mut transport = MockMsgSender::new();
3533        transport
3534            .expect_send_message()
3535            .times(2)
3536            .returning(|_, _| futures::future::ok(()).boxed());
3537
3538        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
3539        let _notifications = tokio::spawn(async move {
3540            pin_mut!(new_session_rx);
3541            while let Some(_session) = new_session_rx.next().await {}
3542        });
3543        let (sender, _handle) = mock_packet_planning(transport);
3544        mgr.start(sender.clone(), new_session_tx)?;
3545        assert!(mgr.is_started());
3546
3547        // First session - should succeed
3548        let pseudonym1 = HoprPseudonym::random();
3549        mgr.handle_incoming_session_initiation(
3550            pseudonym1,
3551            StartInitiation {
3552                challenge: MIN_CHALLENGE,
3553                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
3554                capabilities: ByteCapabilities(Capabilities::empty()),
3555                additional_data: 0,
3556            },
3557        )
3558        .await?;
3559
3560        // Verify one session exists
3561        assert_eq!(mgr.active_sessions().len(), 1);
3562
3563        // Second session - should fail with TooManySessions
3564        let pseudonym2 = HoprPseudonym::random();
3565        let _result = mgr
3566            .handle_incoming_session_initiation(
3567                pseudonym2,
3568                StartInitiation {
3569                    challenge: MIN_CHALLENGE,
3570                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
3571                    capabilities: ByteCapabilities(Capabilities::empty()),
3572                    additional_data: 0,
3573                },
3574            )
3575            .await;
3576
3577        // The error is handled internally (sends SessionError), so result is Ok
3578        // But we can verify no new session was added
3579        assert_eq!(mgr.active_sessions().len(), 1);
3580
3581        // Cleanup: close sender and await handle
3582        sender.close_channel();
3583        let _ = _handle.await;
3584
3585        Ok(())
3586    }
3587
3588    /// Verifies the early `TooManySessions` return at the top of `new_session` (line 767).
3589    /// Unlike `session_manager_should_reject_new_session_when_max_sessions_reached`, which fills
3590    /// incoming slots and hits the slot-guard path at line 957, this test fills all `maximum_sessions`
3591    /// slots so that the `if self.cfg.maximum_sessions <= self.sessions.entry_count()` check fires
3592    /// before any message is sent.
3593    #[test_log::test(tokio::test)]
3594    async fn new_session_returns_too_many_sessions_when_cache_is_full() -> anyhow::Result<()> {
3595        use hopr_utils::network_types::prelude::SealedHost;
3596
3597        let cfg = SessionManagerConfig {
3598            maximum_sessions: 2,
3599            idle_timeout: Duration::from_secs(3600),
3600            ..Default::default()
3601        };
3602        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> = SessionManager::new(cfg);
3603
3604        let mut transport = MockMsgSender::new();
3605        // Two incoming sessions: first sends SessionEstablished, second sends SessionError (no slots).
3606        transport
3607            .expect_send_message()
3608            .times(2)
3609            .returning(|_, _| futures::future::ok(()).boxed());
3610
3611        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
3612        let _notifications = tokio::spawn(async move {
3613            pin_mut!(new_session_rx);
3614            while let Some(_session) = new_session_rx.next().await {}
3615        });
3616        let (sender, _handle) = mock_packet_planning(transport);
3617        mgr.start(sender.clone(), new_session_tx)?;
3618        assert!(mgr.is_started());
3619
3620        // Fill the cache with two incoming sessions (Exits).
3621        for i in 0..2 {
3622            let pseudonym = HoprPseudonym::random();
3623            mgr.handle_incoming_session_initiation(
3624                pseudonym,
3625                StartInitiation {
3626                    challenge: MIN_CHALLENGE + i as u64,
3627                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
3628                    capabilities: ByteCapabilities(Capabilities::empty()),
3629                    additional_data: 0,
3630                },
3631            )
3632            .await?;
3633        }
3634        assert_eq!(mgr.active_sessions().len(), 2);
3635
3636        // Third outgoing call hits the early return before sending anything.
3637        let result = mgr
3638            .new_session(
3639                Address::from(&ChainKeypair::random()),
3640                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
3641                SessionClientConfig {
3642                    surb_management: None,
3643                    ..Default::default()
3644                },
3645            )
3646            .await;
3647
3648        assert!(result.is_err());
3649        assert!(matches!(
3650            result.unwrap_err(),
3651            TransportSessionError::Manager(SessionManagerError::TooManySessions)
3652        ));
3653
3654        sender.close_channel();
3655        let _ = _handle.await;
3656        Ok(())
3657    }
3658
3659    /// Verifies that `session_initiations` is cleaned up when `new_session` fails to
3660    /// send the StartSession message (e.g. the underlying channel is closed).
3661    #[test_log::test(tokio::test)]
3662    async fn new_session_removes_challenge_on_send_failure() -> anyhow::Result<()> {
3663        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
3664            SessionManager::new(Default::default());
3665
3666        // Create a channel whose receiver is dropped immediately.  When the mock
3667        // transport tries to `send` over this channel the call will return an error,
3668        // which propagates up through `send_via_msg_sender` as
3669        // `TransportSessionError::packet_sending`.
3670        let (tx, rx) = futures::channel::mpsc::unbounded();
3671        drop(rx);
3672
3673        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
3674        let _notifications = tokio::spawn(async move {
3675            pin_mut!(new_session_rx);
3676            while let Some(_session) = new_session_rx.next().await {}
3677        });
3678        mgr.start(tx, new_session_tx)?;
3679        assert!(mgr.is_started());
3680
3681        // Verify that sending fails because the receiver is gone.
3682        let result = mgr
3683            .new_session(
3684                Address::from(&ChainKeypair::random()),
3685                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
3686                SessionClientConfig {
3687                    surb_management: None,
3688                    ..Default::default()
3689                },
3690            )
3691            .await;
3692
3693        assert!(result.is_err());
3694        // The challenge must have been removed from `session_initiations` even
3695        // though the send failed.
3696        assert_eq!(
3697            mgr.session_initiations.entry_count(),
3698            0,
3699            "session_initiations was not cleaned up after send failure"
3700        );
3701
3702        Ok(())
3703    }
3704
3705    /// Verifies that `session_initiations` is cleaned up when the session initiation
3706    /// times out waiting for a response (neither `SessionEstablished` nor
3707    /// `SessionError` arrives).
3708    #[test_log::test(tokio::test)]
3709    async fn new_session_removes_challenge_on_timeout() -> anyhow::Result<()> {
3710        let cfg = SessionManagerConfig {
3711            initiation_timeout_base: Duration::from_millis(100),
3712            ..Default::default()
3713        };
3714
3715        let alice_mgr = SessionManager::new(cfg);
3716        let bob_mgr = SessionManager::new(Default::default());
3717
3718        let bob_peer: Address = (&ChainKeypair::random()).into();
3719
3720        let mut alice_transport = MockMsgSender::new();
3721        let bob_transport = MockMsgSender::new();
3722
3723        // Alice sends the StartSession message; Bob never responds.
3724        alice_transport
3725            .expect_send_message()
3726            .once()
3727            .returning(|_, _| futures::future::ok(()).boxed());
3728
3729        let (alice_sender, _alice_handle) = mock_packet_planning(alice_transport);
3730        let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
3731        alice_mgr.start(alice_sender.clone(), new_session_tx_alice)?;
3732        assert!(alice_mgr.is_started());
3733
3734        let (bob_sender, _bob_handle) = mock_packet_planning(bob_transport);
3735        let (new_session_tx_bob, _) = futures::channel::mpsc::channel(1024);
3736        bob_mgr.start(bob_sender.clone(), new_session_tx_bob)?;
3737        assert!(bob_mgr.is_started());
3738
3739        // Record how many entries are in `session_initiations` before the call.
3740        assert_eq!(alice_mgr.session_initiations.entry_count(), 0);
3741
3742        let result = alice_mgr
3743            .new_session(
3744                bob_peer,
3745                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
3746                SessionClientConfig {
3747                    capabilities: None.into(),
3748                    pseudonym: None,
3749                    surb_management: None,
3750                    ..Default::default()
3751                },
3752            )
3753            .await;
3754
3755        assert!(matches!(result, Err(TransportSessionError::Timeout)));
3756        // The pending challenge must have been removed from `session_initiations`
3757        // after the timeout error propagated.
3758        assert_eq!(
3759            alice_mgr.session_initiations.entry_count(),
3760            0,
3761            "session_initiations was not cleaned up after timeout"
3762        );
3763
3764        Ok(())
3765    }
3766
3767    /// Convenience: a session data packet carrying `payload`.
3768    #[cfg(test)]
3769    fn session_data_packet(payload: &[u8]) -> anyhow::Result<ApplicationDataIn> {
3770        Ok(ApplicationDataIn {
3771            data: ApplicationData::new(SESSION_APPLICATION_TAG, payload)?,
3772            packet_info: Default::default(),
3773        })
3774    }
3775
3776    /// The manager sink type used by the dispatch drop tests, which never open a session and so
3777    /// never exercise the sink — its concrete type only needs to be nameable.
3778    #[cfg(test)]
3779    type TestManager =
3780        SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>>;
3781
3782    // Test B (fully deregistered): a data packet for a session id absent from the registry is a
3783    // benign, quiet drop counted as UnknownData — NOT an error, and NOT one ERROR log per packet.
3784    // Under the old behaviour this returned `Err(UnknownData)` and the caller logged it at ERROR,
3785    // which is what produced the per-packet spam during the incident.
3786    #[test_log::test(tokio::test)]
3787    async fn dispatching_to_an_unregistered_session_should_be_a_quiet_counted_drop() -> anyhow::Result<()> {
3788        let mgr: TestManager = SessionManager::new(Default::default());
3789
3790        let pseudonym = HoprPseudonym::random();
3791        let before = crate::counters::session_unknown_data_drop_count();
3792
3793        // N packets → N drops, each an Ok(Dropped(Unregistered)), no panic, no unbounded error.
3794        const N: usize = 5;
3795        for _ in 0..N {
3796            let result = mgr.dispatch_message(pseudonym, session_data_packet(b"test data")?);
3797            assert!(
3798                matches!(result, Ok(DispatchResult::Dropped(DropReason::Unregistered))),
3799                "unregistered-session packet must be a benign Dropped(Unregistered), got {result:?}"
3800            );
3801        }
3802
3803        // Counter advanced by at least N (>= rather than == because it is a process-global atomic
3804        // other tests in this binary may also touch).
3805        assert!(crate::counters::session_unknown_data_drop_count() >= before + N);
3806
3807        Ok(())
3808    }
3809
3810    // Test A (primary regression): a data packet for a session whose data sink has been dropped
3811    // while the slot is still registered must be dropped quietly as SinkClosed — NOT an error.
3812    // This is the "sending on a disconnected channel" ERROR from the incident. The bad-behaviour
3813    // assertion is the silent-vs-loud distinction: the result is Ok(Dropped(SinkClosed)), not Err,
3814    // and specifically not SinkFull (which would misattribute a teardown as backpressure).
3815    #[test_log::test(tokio::test)]
3816    async fn dispatching_to_a_session_whose_sink_closed_should_be_a_quiet_counted_drop() -> anyhow::Result<()> {
3817        let mgr: TestManager = SessionManager::new(Default::default());
3818
3819        // `pre_populate_session` registers the slot and immediately drops its receiver, so the
3820        // sink is closed while the slot is still present — exactly the incident's race.
3821        let pseudonym = HoprPseudonym::random();
3822        mgr.pre_populate_session(pseudonym, DestinationRouting::Return(pseudonym.into()));
3823
3824        let before = crate::counters::session_inbox_closed_drop_count();
3825        let result = mgr.dispatch_message(pseudonym, session_data_packet(b"after teardown")?);
3826
3827        assert!(
3828            matches!(result, Ok(DispatchResult::Dropped(DropReason::SinkClosed))),
3829            "closed-sink packet must be a benign Dropped(SinkClosed), not an error or SinkFull, got {result:?}"
3830        );
3831        assert!(crate::counters::session_inbox_closed_drop_count() > before);
3832
3833        Ok(())
3834    }
3835
3836    // Genuine backpressure (inbox full, reader alive but not keeping up) is distinct from a
3837    // teardown: it is Dropped(SinkFull) and counted in SESSION_INBOX_DROPS, not the closed counter.
3838    #[test_log::test(tokio::test)]
3839    async fn dispatching_to_a_full_session_inbox_should_be_a_backpressure_drop() -> anyhow::Result<()> {
3840        // Capacity 1 so a single unread packet fills the inbox deterministically.
3841        let cfg = SessionManagerConfig {
3842            session_forward_capacity: 1,
3843            ..Default::default()
3844        };
3845        let mgr: TestManager = SessionManager::new(cfg);
3846
3847        // Keep the receiver alive but never read it, so the channel is open but saturates.
3848        let pseudonym = HoprPseudonym::random();
3849        let _rx = mgr.pre_populate_session_with_receiver(pseudonym, DestinationRouting::Return(pseudonym.into()));
3850
3851        // First packet fills the single slot and is accepted.
3852        let accepted = mgr.dispatch_message(pseudonym, session_data_packet(b"fills the slot")?);
3853        assert!(
3854            matches!(accepted, Ok(DispatchResult::Processed)),
3855            "first packet should be accepted, got {accepted:?}"
3856        );
3857
3858        // Second packet finds the inbox full → backpressure drop.
3859        let before = crate::counters::session_inbox_drop_count();
3860        let overflow = mgr.dispatch_message(pseudonym, session_data_packet(b"overflows")?);
3861        assert!(
3862            matches!(overflow, Ok(DispatchResult::Dropped(DropReason::SinkFull))),
3863            "overflow packet must be a Dropped(SinkFull) backpressure drop, got {overflow:?}"
3864        );
3865        assert!(crate::counters::session_inbox_drop_count() > before);
3866
3867        Ok(())
3868    }
3869
3870    #[test_log::test(tokio::test)]
3871    async fn session_manager_should_return_true_when_closing_existing_session() -> anyhow::Result<()> {
3872        use hopr_utils::network_types::prelude::SealedHost;
3873
3874        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
3875            SessionManager::new(Default::default());
3876
3877        let mut transport = MockMsgSender::new();
3878        transport
3879            .expect_send_message()
3880            .once()
3881            .returning(|_, _| futures::future::ok(()).boxed());
3882
3883        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
3884        let _notifications = tokio::spawn(async move {
3885            pin_mut!(new_session_rx);
3886            while let Some(_session) = new_session_rx.next().await {}
3887        });
3888        let (sender, _handle) = mock_packet_planning(transport);
3889        mgr.start(sender.clone(), new_session_tx)?;
3890        assert!(mgr.is_started());
3891
3892        // Create a session
3893        let pseudonym = HoprPseudonym::random();
3894        mgr.handle_incoming_session_initiation(
3895            pseudonym,
3896            StartInitiation {
3897                challenge: MIN_CHALLENGE,
3898                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
3899                capabilities: ByteCapabilities(Capabilities::empty()),
3900                additional_data: 0,
3901            },
3902        )
3903        .await?;
3904
3905        // Verify session exists
3906        assert_eq!(mgr.active_sessions().len(), 1);
3907
3908        // Close the session - should return true
3909        let result = mgr.close_session(&pseudonym);
3910        assert!(result, "closing existing session should return true");
3911
3912        // Verify session is closed
3913        assert_eq!(mgr.active_sessions().len(), 0);
3914
3915        // Cleanup: close sender and await handle
3916        sender.close_channel();
3917        let _ = _handle.await;
3918
3919        Ok(())
3920    }
3921
3922    #[test_log::test(tokio::test)]
3923    async fn session_manager_should_update_buffer_level_on_keep_alive_with_balancer_state_flag() -> anyhow::Result<()> {
3924        use std::sync::atomic::Ordering;
3925
3926        let alice_pseudonym = HoprPseudonym::random();
3927        let session_id = alice_pseudonym;
3928        let initial_buffer_level = 100u64;
3929        let new_buffer_level = 200u64;
3930
3931        let balancer_cfg = SurbBalancerConfig {
3932            target_surb_buffer_size: 1000,
3933            max_surbs_per_sec: 100,
3934            ..Default::default()
3935        };
3936
3937        let alice_mgr =
3938            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
3939
3940        let (new_session_tx, _) = futures::channel::mpsc::channel(1024);
3941        let (mock_sender, _) = futures::channel::mpsc::unbounded();
3942        let _ahs = alice_mgr.start(mock_sender, new_session_tx)?;
3943        assert!(alice_mgr.is_started());
3944
3945        let (dummy_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
3946        let peer_address: Address = (&ChainKeypair::random()).into();
3947        alice_mgr.sessions.insert(
3948            session_id,
3949            SessionSlot {
3950                session_tx: dummy_tx,
3951                routing_opts: DestinationRouting::Forward {
3952                    destination: Box::new(peer_address.into()),
3953                    pseudonym: Some(alice_pseudonym),
3954                    forward_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN),
3955                    return_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN).into(),
3956                },
3957                abort_handles: Default::default(),
3958                surb_mgmt: Arc::new(BalancerStateValues::from(balancer_cfg)),
3959                surb_estimator: Default::default(),
3960            },
3961        );
3962
3963        // Set initial buffer level
3964        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
3965        session_slot
3966            .surb_mgmt
3967            .buffer_level
3968            .store(initial_buffer_level, Ordering::Relaxed);
3969        drop(session_slot);
3970
3971        // Verify initial buffer level
3972        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
3973        assert_eq!(session_slot.surb_mgmt.buffer_level(), initial_buffer_level);
3974        drop(session_slot);
3975
3976        // Create keep-alive message with BalancerState flag
3977        let ka = KeepAliveMessage::<SessionId> {
3978            session_id,
3979            flags: KeepAliveFlag::BalancerState.into(),
3980            additional_data: new_buffer_level,
3981        };
3982        let app_data: ApplicationData = HoprStartProtocol::KeepAlive(ka).try_into()?;
3983        let app_data_in = ApplicationDataIn {
3984            data: app_data,
3985            packet_info: Default::default(),
3986        };
3987
3988        // Dispatch the keep-alive message
3989        alice_mgr.dispatch_message(alice_pseudonym, app_data_in)?;
3990
3991        // Poll until the background task has processed the keep-alive
3992        tokio::time::timeout(Duration::from_secs(1), async {
3993            loop {
3994                if let Some(slot) = alice_mgr.sessions.get(&session_id)
3995                    && slot.surb_mgmt.buffer_level() == new_buffer_level
3996                {
3997                    break;
3998                }
3999                tokio::time::sleep(Duration::from_millis(10)).await;
4000            }
4001        })
4002        .await
4003        .context("keep-alive BalancerState update timed out")?;
4004
4005        // Verify buffer level was updated
4006        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
4007        assert_eq!(
4008            session_slot.surb_mgmt.buffer_level(),
4009            new_buffer_level,
4010            "buffer level should be updated via keep-alive with BalancerState flag"
4011        );
4012
4013        Ok(())
4014    }
4015
4016    #[test_log::test(tokio::test)]
4017    async fn session_manager_should_update_target_on_keep_alive_with_balancer_target_flag() -> anyhow::Result<()> {
4018        use std::sync::atomic::Ordering;
4019
4020        let alice_pseudonym = HoprPseudonym::random();
4021        let session_id = alice_pseudonym;
4022        let initial_target = 1000u64;
4023        let new_target = 2000u64;
4024
4025        let balancer_cfg = SurbBalancerConfig {
4026            target_surb_buffer_size: initial_target,
4027            max_surbs_per_sec: 100,
4028            ..Default::default()
4029        };
4030
4031        let alice_mgr =
4032            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
4033
4034        let (new_session_tx, _) = futures::channel::mpsc::channel(1024);
4035        let (mock_sender, _) = futures::channel::mpsc::unbounded();
4036        let _ahs = alice_mgr.start(mock_sender, new_session_tx)?;
4037        assert!(alice_mgr.is_started());
4038
4039        let (dummy_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
4040        alice_mgr.sessions.insert(
4041            session_id,
4042            SessionSlot {
4043                session_tx: dummy_tx,
4044                routing_opts: DestinationRouting::Return(SurbMatcher::Pseudonym(alice_pseudonym)),
4045                abort_handles: Default::default(),
4046                surb_mgmt: Arc::new(BalancerStateValues::from(balancer_cfg)),
4047                surb_estimator: Default::default(),
4048            },
4049        );
4050
4051        // Verify initial target
4052        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
4053        assert_eq!(
4054            session_slot.surb_mgmt.controller_bounds().target(),
4055            initial_target,
4056            "initial target should be set"
4057        );
4058        drop(session_slot);
4059
4060        // Create keep-alive message with BalancerTarget flag
4061        let ka = KeepAliveMessage::<SessionId> {
4062            session_id,
4063            flags: KeepAliveFlag::BalancerTarget.into(),
4064            additional_data: new_target,
4065        };
4066        let app_data: ApplicationData = HoprStartProtocol::KeepAlive(ka).try_into()?;
4067        let app_data_in = ApplicationDataIn {
4068            data: app_data,
4069            packet_info: Default::default(),
4070        };
4071
4072        // Dispatch the keep-alive message
4073        alice_mgr.dispatch_message(alice_pseudonym, app_data_in)?;
4074
4075        // Poll until the background task has processed the keep-alive
4076        tokio::time::timeout(Duration::from_secs(1), async {
4077            loop {
4078                if let Some(slot) = alice_mgr.sessions.get(&session_id)
4079                    && slot.surb_mgmt.target_surb_buffer_size.load(Ordering::Relaxed) == new_target
4080                {
4081                    break;
4082                }
4083                tokio::time::sleep(Duration::from_millis(10)).await;
4084            }
4085        })
4086        .await
4087        .context("keep-alive BalancerTarget update timed out")?;
4088
4089        // Verify target was updated
4090        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
4091        assert_eq!(
4092            session_slot.surb_mgmt.target_surb_buffer_size.load(Ordering::Relaxed),
4093            new_target,
4094            "target buffer size should be updated via keep-alive with BalancerTarget flag"
4095        );
4096
4097        Ok(())
4098    }
4099
4100    #[test_log::test(tokio::test)]
4101    async fn session_manager_should_evict_idle_session_and_call_close_callback() -> anyhow::Result<()> {
4102        use hopr_utils::network_types::prelude::SealedHost;
4103
4104        let cfg = SessionManagerConfig {
4105            maximum_sessions: 1,
4106            idle_timeout: Duration::from_millis(100),
4107            ..Default::default()
4108        };
4109        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
4110            SessionManager::new(cfg);
4111
4112        let mut transport = MockMsgSender::new();
4113        transport
4114            .expect_send_message()
4115            .times(1)
4116            .returning(|_, _| futures::future::ok(()).boxed());
4117
4118        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
4119        let _notifications = tokio::spawn(async move {
4120            pin_mut!(new_session_rx);
4121            while let Some(_session) = new_session_rx.next().await {}
4122        });
4123        let (sender, _handle) = mock_packet_planning(transport);
4124        mgr.start(sender.clone(), new_session_tx)?;
4125        assert!(mgr.is_started());
4126
4127        // Create first session
4128        let pseudonym1 = HoprPseudonym::random();
4129        mgr.handle_incoming_session_initiation(
4130            pseudonym1,
4131            StartInitiation {
4132                challenge: MIN_CHALLENGE,
4133                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
4134                capabilities: ByteCapabilities(Capabilities::empty()),
4135                additional_data: 0,
4136            },
4137        )
4138        .await?;
4139
4140        // Verify first session exists
4141        assert_eq!(mgr.active_sessions().len(), 1);
4142
4143        // Wait for the session to expire (idle_timeout = 100ms)
4144        tokio::time::sleep(Duration::from_millis(200)).await;
4145        mgr.sessions.run_pending_tasks();
4146
4147        // Verify session was evicted (cache should be empty now)
4148        assert_eq!(
4149            mgr.active_sessions().len(),
4150            0,
4151            "idle session should be evicted after timeout"
4152        );
4153
4154        Ok(())
4155    }
4156
4157    #[test_log::test(tokio::test)]
4158    async fn session_manager_should_reject_new_session_when_max_sessions_reached_no_eviction() -> anyhow::Result<()> {
4159        use hopr_utils::network_types::prelude::SealedHost;
4160
4161        // Create manager with max 1 session
4162        let cfg = SessionManagerConfig {
4163            maximum_sessions: 1,
4164            idle_timeout: Duration::from_secs(3600), // Long timeout so eviction doesn't happen
4165            ..Default::default()
4166        };
4167        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
4168            SessionManager::new(cfg);
4169
4170        let mut transport = MockMsgSender::new();
4171        transport
4172            .expect_send_message()
4173            .times(2)
4174            .returning(|_, _| futures::future::ok(()).boxed());
4175
4176        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
4177        let _notifications = tokio::spawn(async move {
4178            pin_mut!(new_session_rx);
4179            while let Some(_session) = new_session_rx.next().await {}
4180        });
4181        let (sender, _handle) = mock_packet_planning(transport);
4182        mgr.start(sender.clone(), new_session_tx)?;
4183        assert!(mgr.is_started());
4184
4185        // Create first session
4186        let pseudonym1 = HoprPseudonym::random();
4187        mgr.handle_incoming_session_initiation(
4188            pseudonym1,
4189            StartInitiation {
4190                challenge: MIN_CHALLENGE,
4191                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
4192                capabilities: ByteCapabilities(Capabilities::empty()),
4193                additional_data: 0,
4194            },
4195        )
4196        .await?;
4197
4198        // Verify first session exists
4199        assert_eq!(mgr.active_sessions().len(), 1);
4200
4201        // Try to create second session - should be rejected (not evicted)
4202        let pseudonym2 = HoprPseudonym::random();
4203        let _result = mgr
4204            .handle_incoming_session_initiation(
4205                pseudonym2,
4206                StartInitiation {
4207                    challenge: MIN_CHALLENGE,
4208                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
4209                    capabilities: ByteCapabilities(Capabilities::empty()),
4210                    additional_data: 0,
4211                },
4212            )
4213            .await;
4214
4215        // Should still have exactly 1 session (the first one)
4216        assert_eq!(
4217            mgr.active_sessions().len(),
4218            1,
4219            "should still have exactly one session - second session should be rejected"
4220        );
4221
4222        // The active session should be the first one (second was rejected)
4223        assert!(
4224            mgr.active_sessions().contains(&pseudonym1),
4225            "the first session should still be active"
4226        );
4227
4228        // Cleanup: close sender and await handle
4229        sender.close_channel();
4230        let _ = _handle.await;
4231
4232        Ok(())
4233    }
4234}