Skip to main content

hopr_transport_session/
manager.rs

1use std::{
2    collections::HashMap,
3    pin::Pin,
4    sync::{Arc, Mutex, OnceLock, atomic::Ordering},
5    time::{Duration, Instant},
6};
7
8use anyhow::anyhow;
9use futures::{Sink, SinkExt, StreamExt, TryStreamExt, channel::oneshot, future::AbortHandle};
10use futures_time::future::FutureExt as TimeExt;
11use hopr_api::{
12    node::{
13        OfferedIncentivization, PixAddressId, PixDepositData, PixDepositDataRequest, SessionAdmissionDecision,
14        SessionAdmissionRequest,
15    },
16    types::{
17        crypto_random::Randomizable,
18        internal::{
19            prelude::HoprPseudonym,
20            routing::{DestinationRouting, RoutingOptions},
21        },
22        primitive::prelude::Address,
23    },
24};
25use hopr_crypto_packet::{
26    HoprPixSpec,
27    prelude::{HOPR_PIX_COMMITMENT_PROOF_SIZE, HoprPixCommitmentProof, HoprPixGroupElement},
28};
29use hopr_protocol_app::prelude::*;
30use hopr_protocol_pix::{
31    EntryShareGenerator, ExitAcknowledgementShareProcessor, GroupEncoding, MAX_POLYS_PER_SSA, PixParams, PixSpec,
32    SsaCommitmentGuard, SsaId, SsaIndex, SsaReconstructor, SsaShareGenerator,
33};
34use hopr_protocol_start::{
35    ErrorIdentifier, KeepAliveFlag, KeepAliveMessage, SsaClientCommitmentMessage, SsaServerCommitmentMessage,
36    StartChallenge, StartErrorReason, StartErrorType, StartEstablished, StartInitiation,
37};
38use hopr_utils::runtime::AbortableList;
39use tracing::{debug, error, info, trace, warn};
40
41#[cfg(feature = "telemetry")]
42use crate::telemetry::{
43    self, SessionLifecycleState, initialize_session_metrics, remove_session_metrics_state, set_session_balancer_data,
44    set_session_state,
45};
46use crate::{
47    AgreedSsaQuota, Capabilities, Capability, HoprSession, HoprSessionOutPixEvent, IncomingSession, SESSION_MTU,
48    SessionClientConfig, SessionId, SessionTarget, SurbBalancerConfig,
49    balancer::{
50        AtomicSurbFlowEstimator, BalancerStateValues, MAX_WAIT_CHUNK, RateController, RateLimitSinkExt, SurbBalancer,
51        SurbControllerWithCorrection, SurbFlowEstimator,
52        pid::{PidBalancerController, PidControllerGains},
53        simple::SimpleBalancerController,
54    },
55    errors::{self, SessionManagerError, TransportSessionError},
56    supervision::{
57        ActionRx, FillRate, GateVerdict, ServiceGate, SessionPixAction, SessionPixCloseReason, SessionPixEvent,
58        SessionPixSupervisorHandle, SupervisorConfig, spawn_supervisor_worker,
59        telemetry::{PixAdmissionRejection, PixGateBlockEpisode, record_gate_closed},
60    },
61    types::{
62        ClosureReason, DEFAULT_PIX_PARAMS, DEFAULT_PIX_QUOTA_RANGE_SPAN, DEFAULT_PIX_SSA_QUOTA, HoprPixDepositData,
63        HoprPixDepositPayload, HoprSessionCapabilities, HoprSessionConfig, HoprSessionInPixEvent, HoprStartProtocol,
64        LOCAL_PIX_SUITE, PIX_QUOTA_BYTES_PER_SHARE, SESSION_APPLICATION_TAG, SessionAdmissionSink, SsaQuota,
65        deposit_data_for_batch, pix_params_to_quota,
66    },
67    utils,
68    utils::{SurbNotificationMode, insert_into_next_slot},
69};
70
71#[cfg(all(feature = "telemetry", not(test)))]
72lazy_static::lazy_static! {
73    static ref METRIC_ACTIVE_SESSIONS: hopr_api::types::telemetry::SimpleGauge = hopr_api::types::telemetry::SimpleGauge::new(
74        "hopr_session_num_active_sessions",
75        "Number of currently active HOPR sessions"
76    ).unwrap();
77    static ref METRIC_NUM_ESTABLISHED_SESSIONS: hopr_api::types::telemetry::SimpleCounter = hopr_api::types::telemetry::SimpleCounter::new(
78        "hopr_session_established_sessions_count",
79        "Number of sessions that were successfully established as an Exit node"
80    ).unwrap();
81    static ref METRIC_NUM_INITIATED_SESSIONS: hopr_api::types::telemetry::SimpleCounter = hopr_api::types::telemetry::SimpleCounter::new(
82        "hopr_session_initiated_sessions_count",
83        "Number of sessions that were successfully initiated as an Entry node"
84    ).unwrap();
85    static ref METRIC_RECEIVED_SESSION_ERRS: hopr_api::types::telemetry::MultiCounter = hopr_api::types::telemetry::MultiCounter::new(
86        "hopr_session_received_error_count",
87        "Number of HOPR session errors received from an Exit node",
88        &["kind"]
89    ).unwrap();
90    static ref METRIC_DISPATCHED_MSGS: hopr_api::types::telemetry::MultiCounter = hopr_api::types::telemetry::MultiCounter::new(
91        "hopr_session_dispatched_messages",
92        "Number dispatched HOPR session messages and their classification",
93        &["kind"]
94    ).unwrap();
95    static ref METRIC_SENT_SESSION_ERRS: hopr_api::types::telemetry::MultiCounter = hopr_api::types::telemetry::MultiCounter::new(
96        "hopr_session_sent_error_count",
97        "Number of HOPR session errors sent to an Entry node",
98        &["kind"]
99    ).unwrap();
100}
101
102/// One outgoing data packet on its way to the wire.
103type EgressItem = (DestinationRouting, ApplicationDataOut);
104
105/// The result of asking the PIX egress gate for permission to send one packet.
106///
107/// `Left` is the answer the gate could give synchronously — a permit, or a refusal because the
108/// Session is being torn down. `Right` is the parked case, and the only one that allocates.
109type EgressPermit = futures::future::Either<
110    std::future::Ready<Result<EgressItem, std::io::Error>>,
111    futures::future::BoxFuture<'static, Result<EgressItem, std::io::Error>>,
112>;
113
114/// Passes one outgoing data packet through a Session's PIX egress gate, if it has one.
115///
116/// Returns a future rather than awaiting so this can sit in a `Sink::with` without the combinator
117/// having to box anything in the common case. The gate answers synchronously whenever service is
118/// available — a relaxed load and a compare-exchange — and only the exhausted-budget path allocates,
119/// which is a path that is about to block regardless.
120///
121/// A Session that negotiated no PIX passes through with no gate at all, so an un-supervised Session
122/// pays one `Option` check per packet and nothing else.
123///
124/// Nothing is measured on the admitted arm, deliberately. That arm is the steady state at the
125/// Session's full packet rate, and what an operator wants from it — how much was served, and on
126/// whose money — is already counted inside the gate's own compare-exchange and flushed once per
127/// supervisor turn by [`PixSessionTelemetry`](crate::supervision::telemetry::PixSessionTelemetry).
128/// Only the refused arm, which is about to allocate a boxed future and block, does any telemetry
129/// work of its own.
130fn acquire_egress_permit(
131    gate: Option<Arc<ServiceGate>>,
132    routing: DestinationRouting,
133    data: ApplicationDataOut,
134) -> EgressPermit {
135    let Some(gate) = gate else {
136        return futures::future::Either::Left(std::future::ready(Ok((routing, data))));
137    };
138
139    match gate.try_acquire_sync() {
140        Ok(GateVerdict::Admitted) => futures::future::Either::Left(std::future::ready(Ok((routing, data)))),
141        // Forwarded rather than reconstructed: the gate now names its own error, so both of its
142        // entry points report the same one and neither caller has to know what a refusal means.
143        Err(closed) => {
144            record_gate_closed();
145            futures::future::Either::Left(std::future::ready(Err(std::io::Error::other(closed))))
146        }
147        // Budget exhausted: park until the supervisor funds the front, restores a successor's
148        // allowance, reports front-cycle progress, or gives up on the Session entirely.
149        //
150        // The episode is opened *here*, at the refusal, and moved into the future, so its clock
151        // covers the whole park and its `Drop` closes it however the park ends — resumed, failed,
152        // or cancelled. Opened outside the `async move` rather than inside it because the refusal
153        // has already happened by the time this returns: a future that is constructed and never
154        // polled is still a packet the gate turned away, and starting the clock at first poll
155        // would under-report every stall by however long the caller took to await it.
156        //
157        // One episode per park rather than per refused packet: this sink is serial, so the next
158        // packet is not offered until this future resolves, and a caller cannot open a second
159        // episode by retrying harder.
160        Ok(GateVerdict::Blocked(reason)) => {
161            let episode = PixGateBlockEpisode::begin(reason);
162            futures::future::Either::Right(Box::pin(async move {
163                let _episode = episode;
164                gate.acquire()
165                    .await
166                    .map(|_| (routing, data))
167                    .map_err(std::io::Error::other)
168            }))
169        }
170    }
171}
172
173/// Closes an established Session.
174///
175/// Logs the counterpart of "new session established" (see [`SessionSlotGuard::commit`]): one line
176/// per Session at INFO, so its whole lifetime can be read from a production log without the
177/// per-frame detail of the socket.
178fn close_session(session_id: SessionId, session_data: SessionSlot, reason: ClosureReason) {
179    tracing::info!(%session_id, %reason, "session closed");
180    release_session_slot(session_id, session_data, reason);
181}
182
183/// Tears down a Session slot: stops its tasks and returns what it holds.
184///
185/// Logs nothing at INFO, because it also cleans up a Session whose setup failed before it was
186/// established (see [`SessionSlotGuard`]). An established Session is closed with [`close_session`].
187#[tracing::instrument(level = "debug", skip(session_data))]
188fn release_session_slot(session_id: SessionId, session_data: SessionSlot, reason: ClosureReason) {
189    debug!("closing session");
190
191    #[cfg(feature = "telemetry")]
192    {
193        set_session_state(&session_id, SessionLifecycleState::Closed);
194        remove_session_metrics_state(&session_id, session_data.pix_egress_gate.get().is_some());
195    }
196
197    if reason != ClosureReason::EmptyRead {
198        // Closing the data sender will also cause it to close from the read side
199        debug!("data tx channel closed on session");
200    }
201
202    // Poison the egress gate before aborting anything. A writer parked on an exhausted predeposit
203    // budget is waiting for a supervisor that is about to stop existing, so it has to be failed
204    // rather than left pending — aborting the tasks first would remove the only thing that could
205    // still have woken it.
206    if let Some(gate) = session_data.pix_egress_gate.get() {
207        gate.poison();
208    }
209
210    // Return this Session's share of the node-level PIX aggregates, after the gate is poisoned so
211    // that no permit it was still handing out is missed. Explicit rather than left to `Drop` for the
212    // reason `CycleBudgetReservation` gives below, and one more: the supervisor worker is spawned
213    // detached and stops only when the last command sender goes with the slot — which the cache does
214    // during a later maintenance pass. Until then the node would report Sessions and cycles that no
215    // longer exist. Idempotent, so the `Drop` backstop is free to run afterwards.
216    if let Some(supervisor) = session_data.pix_supervisor.get() {
217        supervisor.telemetry.release();
218    }
219
220    // Terminate any additional tasks spawned by the Session. This is also what releases the PIX
221    // reconstructor state: the action driver holds a commitment guard per live cycle, and aborting
222    // it drops them.
223    session_data.abort_handles.lock().abort_all();
224
225    // And return the memory those cycles were admitted against, now rather than whenever the last
226    // clone of this slot happens to be dropped. Idempotent, so the slot's own `Drop` — including
227    // the cache's deferred one — is free to run afterwards.
228    if let Some(reservation) = session_data.cycle_budget.as_ref() {
229        reservation.release();
230    }
231
232    #[cfg(all(feature = "telemetry", not(test)))]
233    METRIC_ACTIVE_SESSIONS.decrement(1.0);
234}
235
236/// How a close is answered for a Session whose SURB buffer is already draining.
237///
238/// A `WriteClosed`/`EmptyRead` is the end-of-stream our own ingress abort produces, so it is absorbed
239/// and the drain continues; anything else — a second operator close, or a peer `SessionError` —
240/// overrules the drain by falling through to the hard teardown.
241///
242/// [`begin_surb_drain`](SessionManager::begin_surb_drain) asks it twice, because a Session can be
243/// found draining either before the transition or by losing it to a concurrent close.
244fn answer_while_draining(reason: ClosureReason) -> Option<bool> {
245    matches!(reason, ClosureReason::WriteClosed | ClosureReason::EmptyRead).then_some(true)
246}
247
248fn initiation_timeout_max_one_way(base: Duration, hops: usize) -> Duration {
249    base * (hops as u32)
250}
251
252/// Conservative lower bound on how many coefficient commitments fit into one `SsaCommit` message.
253///
254/// Mirrors the sizing in `SsaClientCommitmentMessage::new_multiple`: payload minus the fixed
255/// prefix (`ssa_index` + `coefficient_index` + `num_polys` + the Start header) and minus a generous
256/// allowance for the CBOR-encoded session id, divided by the per-entry cost
257/// (`PolynomialIndex` + one serialized group element). Using a *lower* bound here means the derived
258/// message count is an over-estimate, which is the safe direction for sizing a queue.
259///
260/// The commitment proof of knowledge is subtracted as well. Every message carries it, since every
261/// message is a constant-term message.
262const MIN_COMMITMENTS_PER_SSA_COMMIT_MSG: usize = {
263    const FIXED_PREFIX: usize = 12;
264    // A `SessionId` is a 10-byte pseudonym; 64 bytes is a large allowance for its CBOR framing.
265    const CBOR_SESSION_ID_ALLOWANCE: usize = 64;
266    const PER_ENTRY: usize = size_of::<hopr_protocol_pix::PolynomialIndex>() + size_of::<HoprPixGroupElement>();
267
268    let usable = ApplicationData::PAYLOAD_SIZE
269        .saturating_sub(FIXED_PREFIX + CBOR_SESSION_ID_ALLOWANCE + HOPR_PIX_COMMITMENT_PROOF_SIZE);
270    let per_msg = usable / PER_ENTRY;
271    if per_msg == 0 { 1 } else { per_msg }
272};
273
274/// Slack added on top of the PIX commitment burst to cover non-PIX Start protocol traffic
275/// (session initiations, establishments, errors, keep-alives).
276const START_PROTOCOL_CHANNEL_RESERVE: usize = 10;
277
278/// Ceiling on the per-session term of [`start_protocol_channel_capacity`].
279///
280/// A session contributes to this channel only while its Start exchange is in flight — a handful of
281/// messages between initiation and establishment — after which it is silent apart from PIX, which
282/// the commitment term already covers. So the queue depth tracks concurrent *handshakes*, not the
283/// total number of sessions the node may manage, and `maximum_managed_sessions` (validated up to
284/// 100 000) is the wrong quantity to size a pre-allocated ring from.
285const MAX_CONCURRENT_START_EXCHANGES: usize = 10_000;
286
287/// Capacity of the Start protocol ingress channel.
288///
289/// This channel is fed by [`SessionManager::dispatch_message`] with `try_send`, and an overflow
290/// **drops** the message. For most Start messages that is recoverable, but a dropped `SsaCommit`
291/// is not: there is no NACK or retransmission, so the corresponding coefficient cell stays empty
292/// forever, the commitment never completes, every subsequent share fails to verify, and the cycle
293/// dies on the supervisor's `max_deposit_wait` deadline.
294///
295/// PIX changed this channel's load from roughly one message per session to the *entire* commitment
296/// set of an SSA cycle, chunked into packet-sized messages, plus a reserve for ordinary Start
297/// traffic. Batching multiplies that: an Exit may ask for up to
298/// [`ssas_per_request`](crate::SupervisorConfig::ssas_per_request) SSAs at once and gets that many
299/// cycles' commitment sets back-to-back, all landing here, so the per-cycle term is scaled by the
300/// configured maximum even when dynamic admission selects less for a particular Session.
301///
302/// The per-cycle burst is bounded by two independent limits, and the capacity takes the smaller:
303///
304/// * `quota_range.end() / PIX_QUOTA_BYTES_PER_SHARE` is `polys × (threshold + surplus)`, an over-estimate by that whole
305///   second factor, since a cycle carries one constant term per polynomial and nothing else. The quota alone does not
306///   reveal how the product splits, so the over-estimate cannot be undone from it.
307/// * [`MAX_POLYS_PER_SSA`] is the number of polynomials [`SessionManager::check_pix_params`] will accept, whatever the
308///   quota says. It therefore bounds the commitments a cycle can ever deliver.
309///
310/// Clamping to the second matters because this capacity is *reserved*, not merely enforced:
311/// `crossfire`'s array flavour pre-allocates every slot when the channel is built. An
312/// operator-settable `quota_range` feeding an unclamped derivation is an allocation with no upper
313/// bound — at `quota_range.end() = 1e13` it asks for 77 GB. Over-provisioning is still the safe
314/// direction within the clamp, and the surviving margin is large: the default dimensions burst
315/// ≈ 320 messages against a capacity term of ≈ 648.
316///
317/// The batch factor is bounded by [`MAX_SSA_BATCH_SIZE`] for the same allocation reason, and is
318/// clamped here rather than being taken on trust from the config, so that callers which build a
319/// `SessionManagerConfig` without going through [`SessionManager::new`] cannot inflate it.
320///
321/// The session term is clamped for the same reason. `maximum_managed_sessions` validates up to
322/// 100 000, and one slot holds a `(HoprPseudonym, HoprStartProtocol)` sized by the enum's largest
323/// variant, so an operator raising the session limit would silently buy a multi-megabyte startup
324/// allocation. Ordinary Start traffic is one message per session *in flight*, not one per session
325/// the node will ever manage, so [`MAX_CONCURRENT_START_EXCHANGES`] is the honest bound.
326fn start_protocol_channel_capacity(cfg: &SessionManagerConfig) -> usize {
327    let max_commitments = (*cfg.pix_config.quota_range.end() / PIX_QUOTA_BYTES_PER_SHARE).min(MAX_POLYS_PER_SSA as u64);
328    let max_commit_msgs = max_commitments.div_ceil(MIN_COMMITMENTS_PER_SSA_COMMIT_MSG as u64);
329    let ssas_per_request = cfg.pix_config.supervision.ssas_per_request.clamp(1, MAX_SSA_BATCH_SIZE) as u64;
330
331    // `usize::try_from` cannot fail on 64-bit targets; saturate rather than panic on 32-bit ones.
332    usize::try_from(max_commit_msgs.saturating_mul(ssas_per_request))
333        .unwrap_or(usize::MAX)
334        .saturating_add(cfg.maximum_sessions.min(MAX_CONCURRENT_START_EXCHANGES))
335        .saturating_add(START_PROTOCOL_CHANNEL_RESERVE)
336}
337
338/// Minimum time the SURB buffer must endure if no SURBs are being produced.
339pub const MIN_SURB_BUFFER_DURATION: Duration = Duration::from_secs(1);
340/// Minimum time between SURB buffer notifications to the Entry.
341pub const MIN_SURB_BUFFER_NOTIFICATION_PERIOD: Duration = Duration::from_secs(1);
342
343/// Fastest return-path rate a Session is assumed to sustain, in packets per second.
344///
345/// Bounds the PIX `max_recovery_time` from below: a deadline this rate cannot meet is treated as one
346/// no Session can, so without fill it is the floor itself, and with fill it caps the `fill.max_rate`
347/// the floor may count on. Stated in packets, because per-packet work is what limits a Session, so it does not
348/// move with any packet or segment size; it carries ≈ 58 Mbps of Session data at full
349/// [`SESSION_MTU`] segments.
350pub const MAX_ASSUMED_SESSION_PACKET_RATE: u64 = 5000;
351
352/// The first challenge value used in Start protocol to initiate a session.
353pub(crate) const MIN_CHALLENGE: StartChallenge = 1;
354
355/// Maximum time to wait for counterparty to receive the target number of SURBs.
356const SESSION_READINESS_TIMEOUT: Duration = Duration::from_secs(10);
357
358/// How long the Entry holds a *near-miss* `SsaRequest` while the Exit's returned data catches up to
359/// the successor boundary.
360///
361/// Absorbs mixer reordering between the returned packets and the `SsaRequest` they earned — the two
362/// travel the same mixed path, so the request can arrive ahead of the last few packets that
363/// justified it. Nothing longer: a sustained shortfall never reaches this wait, because the gate only
364/// enters it for a request already within one emission window of the boundary.
365///
366/// Comfortably inside the Exit's `max_ssa_delivery_time` (20 s by default, batch-scaled), which is
367/// what closes the Session as `CommitmentTimeout` if no `SsaCommit` follows. That budget also has to
368/// cover generating the commitments and shipping the burst, so this takes a small slice of it.
369const SSA_SUCCESSOR_SERVICE_WAIT: Duration = Duration::from_secs(2);
370
371/// How often [`SSA_SUCCESSOR_SERVICE_WAIT`] re-reads the returned-packet count.
372///
373/// Polled rather than notified: the alternative is waking a waiter from the Session receive path,
374/// which would put a branch on every inbound packet to serve a case that arises once per SSA cycle.
375const SSA_SUCCESSOR_SERVICE_POLL: Duration = Duration::from_millis(250);
376
377/// Minimum timeout until an unfinished frame is discarded.
378const MIN_FRAME_TIMEOUT: Duration = Duration::from_millis(10);
379
380/// Hard ceiling on both SSA batch-size knobs, whatever the configuration says.
381///
382/// The current maximum value of 9 is based on the current available pool limitations.
383///
384/// Deliberately far below the wire limit (`StartProtocol::MAX_SSAS_PER_REQUEST`, 27), which only
385/// bounds what can be *decoded*. The real cost is paid on both sides of the exchange and neither is
386/// small at the profiled dimensions:
387///
388/// * Entry: every entry in the batch is a full `new_ssa_commitment` (hundreds of thousands of EC commitments), its own
389///   burst of thousands of `SsaCommit` packets, and its own `ReadyToDeposit` — i.e. its own on-chain deposit.
390/// * Exit: every entry is a live reconstructor cycle, held until that cycle recovers — worst case ≈41 MiB at the
391///   deployed dimensions (`hopr_protocol_pix::peak_cycle_bytes`), so ≈820 MiB per Session at this ceiling, before the
392///   pipelining factor. That is what a Session reserves against [`IncomingSessionPixConfig::max_live_cycle_bytes`], so
393///   raising the batch size directly divides how many PIX Sessions the node will accept.
394///
395/// It also bounds the supervisor's deadline scaling: a batch multiplies both
396/// [`max_ssa_delivery_time`](crate::SupervisorConfig::max_ssa_delivery_time) and
397/// [`max_deposit_wait`](crate::SupervisorConfig::max_deposit_wait), and that product is what decides
398/// how long a Session may be served unincentivized.
399///
400/// Both [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request) and
401/// [`SessionManagerConfig::max_ssas_per_ssa_request`] are clamped to `1..=Self` where they are read,
402/// so a programmatically built config that never calls `validate()` cannot exceed it.
403pub const MAX_SSA_BATCH_SIZE: usize = 9;
404
405// The Entry's retention of commitment material has to outlast the batch that material belongs to.
406// `hopr-protocol-pix` cannot see this figure, so it derives its cap from it in prose; this is the
407// half of that derivation which has to hold. A cycle must stay retransmittable at least until its
408// own batch has finished being committed, or a commitment packet lost early in a large batch becomes
409// unrepairable while the rest of the batch is still arriving. Slack beyond one batch is a judgement
410// call, which is why this is an inequality and not the equality the two figures satisfy today.
411const _: () = assert!(
412    hopr_protocol_pix::MAX_RETAINED_COMMITMENT_CYCLES >= MAX_SSA_BATCH_SIZE,
413    "MAX_SSA_BATCH_SIZE exceeds MAX_RETAINED_COMMITMENT_CYCLES, so a cycle can be evicted from the Entry's retention \
414     before its own batch is fully committed and its commitment stops being repairable"
415);
416
417/// How long an Exit waits for the deposit pool to answer a
418/// [`DepositDataRequest`](HoprSessionOutPixEvent::DepositDataRequest) before sending the batch
419/// without what has not arrived.
420pub const DEPOSIT_DATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
421
422/// SSA batches whose reconstructor state can be live on the Exit at the same moment.
423///
424/// Two, enforced at allocation rather than inferred from share-order policy. The supervisor tags
425/// every requested generation and defers a successor while this many generations still own live
426/// reconstructor state; it also checks the equivalent full-batch cycle count charged below. Full
427/// recovery releases cycle state immediately, after which a deferred request is retried.
428///
429/// Used as the pipelining factor when a Session reserves against
430/// [`IncomingSessionPixConfig::max_live_cycle_bytes`].
431pub const MAX_OVERLAPPING_BATCHES: u64 = 2;
432
433/// What a PIX Session at these dimensions costs the node's live-cycle budget.
434///
435/// The worst case one cycle can hold, times every cycle that can be live at once. Both factors come
436/// from configuration rather than observation, because the charge is made before the Session exists.
437/// `ssas_per_request` is clamped for the reason [`SessionManager::new`] clamps it — a
438/// programmatically built config must not be able to understate the reservation and then overrun it.
439pub fn cycle_budget_for(params: &PixParams, ssas_per_request: usize) -> u64 {
440    hopr_protocol_pix::peak_cycle_bytes::<HoprPixSpec>(params)
441        .saturating_mul(ssas_per_request.clamp(1, MAX_SSA_BATCH_SIZE) as u64)
442        .saturating_mul(MAX_OVERLAPPING_BATCHES)
443}
444
445/// The largest reservation any offer inside a `quota_range` ending at `quota_bytes` can produce.
446///
447/// The quota fixes `polys × (threshold + surplus)` but not the split, and the two terms of
448/// [`hopr_protocol_pix::peak_cycle_bytes`] pull opposite ways — the share buffers want a high
449/// threshold, the per-polynomial overhead wants many polynomials — so the maximum is found by
450/// walking the thresholds rather than by a closed form. 254 iterations, once, at config load.
451///
452/// `surplus = 0` throughout, which is what makes each candidate the worst of its threshold: the
453/// surplus is priced into the quota but holds no reconstructor state, so any surplus at all buys
454/// fewer polynomials for the same quota.
455///
456/// Used to reject a [`IncomingSessionPixConfig::max_live_cycle_bytes`] that could never admit even
457/// one Session at the dimensions its own `quota_range` advertises.
458pub fn max_cycle_budget_for_quota(quota_bytes: u64, ssas_per_request: usize) -> u64 {
459    let quota_shares = quota_bytes / PIX_QUOTA_BYTES_PER_SHARE;
460
461    (hopr_protocol_pix::MIN_POLY_THRESHOLD..=hopr_protocol_pix::MAX_POLY_THRESHOLD)
462        .filter_map(|threshold| {
463            let polys = u16::try_from((quota_shares / threshold as u64).min(MAX_POLYS_PER_SSA as u64)).ok()?;
464            PixParams::try_new(polys, threshold, 0, LOCAL_PIX_SUITE).ok()
465        })
466        .map(|params| cycle_budget_for(&params, ssas_per_request))
467        .max()
468        .unwrap_or_default()
469}
470
471/// Default for [`SessionManagerConfig::max_ssas_per_ssa_request`] — how many SSA commitments an Entry
472/// accepts in a single [`SsaServerCommitmentMessage`].
473///
474/// Pipelining needs at most one cycle in flight ahead of the active one, so 2 leaves room for an Exit
475/// batching at the default without turning one inbound packet into an unbounded amount of Entry work
476/// and on-chain deposits.
477pub const DEFAULT_MAX_SSAS_PER_SSA_REQUEST: usize = 2;
478
479/// Default for [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request) —
480/// how many SSAs an Exit may ask for in a single [`SsaServerCommitmentMessage`]. It is the maximum
481/// in dynamic mode and the exact batch in fixed mode.
482///
483/// One, so that the default configuration produces exactly the unbatched exchange: same wire bytes,
484/// same supervisor deadlines, same Start protocol channel capacity.
485pub const DEFAULT_SSAS_PER_SSA_REQUEST: usize = 1;
486
487/// Timeout when sending Start protocol messages to the sink
488const EXTERNAL_SEND_TIMEOUT: Duration = Duration::from_millis(200);
489
490// Needs to use an UnboundedSender instead of oneshot
491// because Moka cache requires the value to be Clone, which oneshot Sender is not.
492// It also cannot be enclosed in an Arc, since calling `send` consumes the oneshot Sender.
493// The Session initiation cache is only present on the Entry (client) side.
494type SessionInitiationCache = moka::sync::Cache<
495    StartChallenge,
496    crossfire::MTx<crossfire::mpsc::One<Result<StartEstablished<SessionId>, StartErrorType<SessionId>>>>,
497>;
498
499/// Handles to streams and tasks spawned by the Session.
500#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
501enum SessionHandles {
502    /// Handle to the stream that facilitates ingress of data from the HOPR network into the Session.
503    Ingress,
504    /// Handle to the process that sends keep-alive messages to the Session recipient (Exit).
505    KeepAlive,
506    /// Handle to the one-shot timer that turns the SURB-level notification on after its first period.
507    ///
508    /// Registered rather than fired and forgotten: it holds an `Arc<PixFillControl>` for as long as
509    /// the period lasts, and no maximum is enforced on that period, so an unregistered task keeps a
510    /// closed Session's fill control alive for however long the operator configured.
511    SurbNotifyDelay,
512    /// Handle to the process that monitors and balances SURBs.
513    Balancer,
514    /// Handle to the task that executes the PIX supervisor's actions.
515    ///
516    /// One per Session rather than one per cycle: the supervisor multiplexes every SSA it tracks
517    /// onto a single action stream.
518    PixActionDriver,
519    /// Handle to the process that awaits the PIX deposit for one SSA.
520    ///
521    /// Carries the inner `SsaIndex` value so that each cycle's observer is independent — pipelining
522    /// must not cancel an earlier cycle's.
523    PixDepositObserver(u32),
524}
525
526impl std::fmt::Display for SessionHandles {
527    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        match self {
529            Self::Ingress => write!(f, "Ingress"),
530            Self::KeepAlive => write!(f, "KeepAlive"),
531            Self::SurbNotifyDelay => write!(f, "SurbNotifyDelay"),
532            Self::Balancer => write!(f, "Balancer"),
533            Self::PixActionDriver => write!(f, "PixActionDriver"),
534            Self::PixDepositObserver(idx) => write!(f, "PixDepositObserver({idx})"),
535        }
536    }
537}
538
539/// The PIX dimensions this Session negotiated, on the Exit side.
540///
541/// Once carried the SSA index and a fault counter as well. Both moved to the supervisor: it decides
542/// *when* a cycle is requested, so it is also what allocates the index, and it is what enforces the
543/// fault limit. Leaving a second copy of either here would have meant two authorities for one fact.
544#[derive(Debug)]
545struct SessionSsaState {
546    /// The dimensions this Session negotiated, as they went on the wire.
547    params: PixParams,
548    /// Highest SSA index this Session has had the generator commit to, `0` before the first batch.
549    ///
550    /// A deliberate second copy of a fact the generator already holds, and the only one of the three
551    /// that came back. The generator's own watermark lives in a cache with an idle retention, so
552    /// "absent" there conflates *never committed* with *state discarded* — and those must not be
553    /// treated alike, because the first is the opening batch every Session begins with and the second
554    /// is a Session whose Entry can no longer serve the cycles it already committed to. This copy
555    /// lives exactly as long as the Session does, so it can tell them apart. See the successor gate in
556    /// [`SessionManager::handle_ssa_request`].
557    ///
558    /// Entry-side only; an Exit never commits and leaves it at zero. [`SsaIndex`] is a `NonZero<u32>`,
559    /// which is what makes `0` an unambiguous "none" rather than a sentinel that has to be defended.
560    committed_ssa_watermark: std::sync::atomic::AtomicU32,
561    /// [`SessionSlot::returned_packets`] as it stood when this Session first committed.
562    ///
563    /// The successor gate counts service *since* that instant, not since the Session opened. Before
564    /// the first commitment the generator holds no polynomials, so the SURBs going out carry no
565    /// shares — and an Exit may legitimately be served up to `max_predeposit_packets` of them before
566    /// any deposit exists. Crediting that prefix would let the Exit bank unpaid service against the
567    /// first cycle it *is* paid for. Everything after this point rides a share.
568    returned_at_first_commit: std::sync::atomic::AtomicU64,
569    /// What this Exit sent as deposit data, per SSA index, for cycles whose deposit address it has
570    /// not learned yet.
571    sent_deposit_data: Arc<parking_lot::Mutex<HashMap<SsaIndex, HoprPixDepositPayload>>>,
572}
573
574impl SessionSsaState {
575    pub fn new(params: PixParams) -> Self {
576        Self {
577            params,
578            committed_ssa_watermark: std::sync::atomic::AtomicU32::new(0),
579            returned_at_first_commit: std::sync::atomic::AtomicU64::new(0),
580            sent_deposit_data: Default::default(),
581        }
582    }
583
584    /// Files the deposit data of a batch the Exit is about to send, for
585    /// [`take_sent_deposit_data`](Self::take_sent_deposit_data) to hand back once the matching
586    /// deposit address is known.
587    pub fn remember_sent_deposit_data(&self, deposit_data: &HashMap<SsaIndex, HoprPixDepositPayload>) {
588        let mut sent = self.sent_deposit_data.lock();
589        for (&ssa_index, payload) in deposit_data {
590            sent.insert(ssa_index, payload.clone());
591        }
592    }
593
594    /// Removes and returns what this Exit sent for `ssa_index`, or empty data if it sent none.
595    pub fn take_sent_deposit_data(&self, session_id: &SessionId, ssa_index: SsaIndex) -> PixDepositData {
596        PixDepositData {
597            id: PixAddressId::new(session_id, ssa_index),
598            data: self
599                .sent_deposit_data
600                .lock()
601                .remove(&ssa_index)
602                .map(|payload| payload.0)
603                .unwrap_or_default(),
604        }
605    }
606
607    /// Highest SSA index committed for this Session, `None` before the first batch.
608    #[inline]
609    pub fn committed_watermark(&self) -> Option<SsaIndex> {
610        SsaIndex::new(self.committed_ssa_watermark.load(Ordering::Relaxed))
611    }
612
613    /// Raises the committed-index watermark to `index` if it is higher, taking the returned-packet
614    /// baseline on the first commitment.
615    ///
616    /// `fetch_max` rather than a store: the gate serialises requests per pseudonym, but a Session's
617    /// slot is shared and monotonicity here must not depend on that lock staying where it is. Its
618    /// *previous* value is also what makes the baseline race-free without leaning on that lock —
619    /// exactly one caller can observe a prior watermark of zero, so exactly one stores.
620    #[inline]
621    pub fn note_committed(&self, index: SsaIndex, returned_packets: u64) {
622        if self.committed_ssa_watermark.fetch_max(index.get(), Ordering::Relaxed) == 0 {
623            self.returned_at_first_commit.store(returned_packets, Ordering::Relaxed);
624        }
625    }
626
627    /// Exit → Entry packets received since this Session's first commitment.
628    ///
629    /// Saturating rather than wrapping: `returned_packets` is monotonic and the baseline is a past
630    /// value of it, so an underflow is impossible — and if one ever became possible, reporting zero
631    /// service closes the successor gate rather than opening it.
632    #[inline]
633    pub fn served_since_first_commit(&self, returned_packets: u64) -> u64 {
634        returned_packets.saturating_sub(self.returned_at_first_commit.load(Ordering::Relaxed))
635    }
636
637    /// Data quota one SSA cycle of these dimensions covers.
638    ///
639    /// The whole cycle, surplus included, as per [`pix_params_to_quota`]: every share a cycle emits
640    /// rides one Exit → Entry packet, so all of them are priced.
641    #[inline]
642    pub const fn quota_per_ssa(&self) -> SsaQuota {
643        pix_params_to_quota(&self.params)
644    }
645}
646
647#[derive(Clone)]
648pub(crate) struct SessionSlot {
649    // Sender does not need to be in Arc, because the receiver part is always
650    // wrapped inside DropAbortable wrapper, with abort handle added to `abort_handles`.
651    session_tx: crossfire::MTx<crossfire::mpsc::Array<ApplicationDataIn>>,
652    routing_opts: DestinationRouting,
653    // Additional tasks spawned by the Session.
654    abort_handles: Arc<parking_lot::Mutex<AbortableList<SessionHandles>>>,
655    // Allows reconfiguring of the SURB balancer on-the-fly
656    // Set on both Entry and Exit sides.
657    surb_mgmt: Arc<BalancerStateValues>,
658    // SURB flow updates happening outside of Session protocol
659    // (e.g., due to Start protocol messages).
660    surb_estimator: AtomicSurbFlowEstimator,
661    // Contains currently active SSA for this Session and its quota
662    current_ssa_state: Arc<OnceLock<SessionSsaState>>,
663    /// Handle to this Session's PIX supervisor, on the Exit side of a PIX-enabled Session.
664    ///
665    /// Populated before the [`HoprSession`] is constructed, so the egress adapters below observe a
666    /// gate rather than racing its installation. Empty on the Entry side and on non-PIX Sessions —
667    /// the Exit is authoritative for the lifecycle, so the Entry runs no supervisor.
668    pix_supervisor: Arc<OnceLock<SessionPixSupervisorHandle>>,
669    /// The egress gate every outgoing data packet of a supervised Session must pass.
670    ///
671    /// Held separately from `pix_supervisor` because the egress path touches it per packet and has
672    /// no use for the rest of the handle.
673    pix_egress_gate: Arc<OnceLock<Arc<ServiceGate>>>,
674    /// Steers the Exit's own Exit → Entry keep-alive stream, where the Session has one.
675    ///
676    /// Exit-side, and populated for every PIX Session as well as for any Session with a configured
677    /// `surb_balance_notify_period` — the two producers share one stream, so they share one handle.
678    /// Empty on the Entry side, whose keep-alives are driven by its SURB balancer instead, and on an
679    /// Exit Session that is neither PIX nor notifying, which originates nothing at all.
680    ///
681    /// A `OnceLock` for the same reason the two fields above are: the action driver that sets rates
682    /// on it is spawned after the slot is published, so it must find the handle rather than race its
683    /// installation.
684    pix_fill: Arc<OnceLock<Arc<PixFillControl>>>,
685    /// Exit → Entry Session packets received on this Session, ever.
686    ///
687    /// Entry-side only; stays zero on the Exit, which is the side that *sends* them. This includes
688    /// both Session data and Start-protocol traffic such as the Exit's SURB-level keep-alives. Each
689    /// packet consumed one return SURB, and a SURB carries at most one PIX share which the Exit can
690    /// only decrypt by using it — so this counts shares the Exit has unlocked, measured without
691    /// asking it. That is what makes it usable as a deposit gate: see the successor gate in
692    /// [`SessionManager::handle_ssa_request`].
693    ///
694    /// Deliberately *not* [`surb_estimator`](Self::surb_estimator)`.consumed`, which increments on
695    /// the same event on this side today. That field is documented as an *estimate* feeding the PID
696    /// balancer, and it is only wired when `surb_management` is enabled; this one decides whether
697    /// money is spent and must be neither. Carrying the increment twice is the cheaper mistake: a
698    /// receive path that forgets this counter makes the gate stricter, never laxer.
699    ///
700    /// Credited from exactly two places, and both of them have to stay:
701    ///
702    /// * the `session_rx` inspectors in `new_session`, once per Session data packet handed to the reader — one in each
703    ///   of the balanced and unbalanced branches;
704    /// * the Start-protocol branch of [`dispatch_message`](SessionManager::dispatch_message), which credits every
705    ///   Start-protocol message arriving on an outgoing Session because that traffic bypasses `session_rx` entirely.
706    ///
707    /// The second is what makes the Exit's keep-alives count, and the gate depends on it: an Exit
708    /// whose application has nothing to say can still complete a funded cycle by keep-alive alone, and
709    /// if those packets earned nothing here its successor would be refused as under-served and the
710    /// Session would die on `CommitmentTimeout` with neither side able to name the cause. It is
711    /// credited at the dispatch level rather than inside a per-message handler on purpose — the credit
712    /// is owed for the SURB the packet consumed, which is true whatever the message turns out to be,
713    /// including one this build fails to parse. Adding a second increment inside
714    /// [`handle_keep_alive`](SessionManager::handle_keep_alive) would double-count it.
715    returned_packets: Arc<std::sync::atomic::AtomicU64>,
716    /// This Session's share of the node's live reconstructor-cycle budget.
717    ///
718    /// Exit-side and PIX-only; `None` everywhere else, since nothing else holds cycle state. Behind
719    /// an `Arc` so the budget is returned when the last clone of this slot goes: a slot is cloned
720    /// into the Session cache and into its [`SessionSlotGuard`], so no single one of them can be
721    /// made responsible for the release.
722    ///
723    /// [`close_session`] returns it explicitly, which is what makes the release simultaneous with
724    /// the closure; the `Drop` behind the `Arc` is the backstop for anything that bypasses that
725    /// function. Both are safe to run because the release is idempotent.
726    cycle_budget: Option<Arc<CycleBudgetReservation>>,
727}
728
729/// The Exit's control over the single Exit → Entry keep-alive stream a Session runs.
730///
731/// One stream, two producers with different jobs, and this is what reconciles them:
732///
733/// * the **SURB-level notification**, sent every `surb_balance_notify_period`, which tells the Entry's balancer how
734///   many SURBs the Exit believes it holds. It is the signal that asks for more, so it must go out even when there are
735///   none left;
736/// * **PIX fill**, whose rate the supervisor plans once a second so that a funded cycle completes inside its own
737///   deadline even if the application never sends a byte.
738///
739/// The effective rate is the faster of the two, because both are the same packet: a keep-alive
740/// carrying the SURB level is exactly what fill needs to send, and sending two streams of them would
741/// double the traffic to say the same thing twice. Every shipped Entry already parses it, counts it
742/// as a consumed SURB so its balancer refills, and — since the fix that precedes this — credits it as
743/// service against the successor gate.
744pub(crate) struct PixFillControl {
745    session_id: SessionId,
746    /// The rate limiter of the keep-alive stream this steers.
747    controller: RateController,
748    /// The configured SURB-level notification period, if the node has one.
749    ///
750    /// `None` disables the notification half entirely; the stream then runs at the fill rate alone,
751    /// which is how a PIX Session on a node with `surb_balance_notify_period: None` still fills.
752    notify_period: Option<Duration>,
753    /// SURBs that must remain estimated-available before a *fill* packet is let through.
754    ///
755    /// The *effective* reserve for this Session, not the configured one: the Entry decides how deep
756    /// the Exit's SURB buffer for the Session is, so a reserve above what the Entry ever intends to
757    /// supply would refuse every fill packet forever. See [`effective_surb_reserve`].
758    min_surb_reserve: u64,
759    /// The Session's SURB flow estimate, which is what the reserve is measured against.
760    estimator: AtomicSurbFlowEstimator,
761    state: parking_lot::Mutex<PixFillState>,
762}
763
764struct PixFillState {
765    /// Whether the initial notification delay has elapsed.
766    ///
767    /// The notification stream deliberately waits one period before starting, so a Session that
768    /// closes immediately never sends one. Fill does not wait, and must not be held behind this.
769    notify_started: bool,
770    /// The rate the PIX supervisor last asked for. [`FillRate::ZERO`] when it is not filling.
771    fill: FillRate,
772    /// When a packet was last let through as the SURB-level notification.
773    ///
774    /// The two producers share a stream, so nothing about a packet says which of them it belongs to.
775    /// This is what makes the distinction: one packet per notification period is the notification and
776    /// is exempt from the SURB reserve, and everything above that rate is fill and is not.
777    last_notify_at: Option<Instant>,
778    /// Whether the SURB reserve is currently withholding fill, so `hopr_pix_sessions_stalled` counts
779    /// this Session once per stall rather than once per refused packet.
780    withholding: bool,
781    /// Whether this stream is draining the SURB buffer of a Session that has already been closed.
782    ///
783    /// Set once, by [`PixFillControl::enter_drain`], and never cleared: a drain ends with the
784    /// Session, and the stream with it.
785    draining: bool,
786}
787
788impl PixFillControl {
789    fn new(
790        session_id: SessionId,
791        controller: RateController,
792        notify_period: Option<Duration>,
793        min_surb_reserve: u64,
794        estimator: AtomicSurbFlowEstimator,
795    ) -> Self {
796        Self {
797            session_id,
798            controller,
799            notify_period,
800            // Clamped rather than trusted: a `SessionManager` can be built from a config that never
801            // went through `validate_pix_supervision`, and a zero reserve would let fill spend the
802            // Session's last SURB. `effective_surb_reserve` applies the same floor, so this is only
803            // load-bearing for the tests that construct a control directly.
804            min_surb_reserve: min_surb_reserve.max(1),
805            estimator,
806            state: parking_lot::Mutex::new(PixFillState {
807                notify_started: false,
808                fill: FillRate::ZERO,
809                last_notify_at: None,
810                withholding: false,
811                draining: false,
812            }),
813        }
814    }
815
816    /// Turns the SURB-level notification on, after its initial delay.
817    ///
818    /// A no-op once the Session is draining, for the reason [`enter_drain`](Self::enter_drain)
819    /// gives — and it has to be checked here rather than only there, because the one-shot delay task
820    /// holds a clone of this control and nothing bounds how long an operator may make its period.
821    pub(crate) fn start_notify(&self) {
822        let mut state = self.state.lock();
823        if state.draining {
824            return;
825        }
826        state.notify_started = true;
827        self.apply(&state);
828    }
829
830    /// Puts the stream into post-close drain mode, silencing the SURB-level notification.
831    ///
832    /// Returns `false` if the stream was draining already, which is how two closes racing through
833    /// `begin_surb_drain` settle which of them owns the drain. The read and the write have to be one
834    /// lock acquisition for that: callers that each saw [`is_draining`](Self::is_draining) before
835    /// either wrote it would both start a drain, and both tell the supervisor about it.
836    ///
837    /// The notification is the single packet [`admit_at`](Self::admit_at) exempts from the SURB
838    /// reserve, and the exemption exists for one reason: it is the message that asks the Entry for
839    /// more SURBs. It asks on behalf of a Session that a drain has already lost — the Entry itself
840    /// may well still be there, but nothing is left to act on the request — so leaving it on would
841    /// originate below the reserve into a buffer nothing is refilling, which is the one failure mode
842    /// [`PixFillConfig::min_surb_reserve`](crate::supervision::PixFillConfig::min_surb_reserve)
843    /// exists to prevent, since a return packet with no SURB holds up every packet this node
844    /// originates. With it off, `notification_due` is always false and the reserve is the only gate.
845    pub(crate) fn enter_drain(&self) -> bool {
846        let mut state = self.state.lock();
847        if state.draining {
848            return false;
849        }
850        state.draining = true;
851        state.notify_started = false;
852        self.apply(&state);
853        true
854    }
855
856    /// Whether this Session is draining its SURB buffer after having been closed.
857    pub(crate) fn is_draining(&self) -> bool {
858        self.state.lock().draining
859    }
860
861    /// The SURBs this Session could still spend on a drain, net of the reserve it must leave behind.
862    ///
863    /// An estimate and an upper bound at that — ring-buffer overwrites, store eviction and
864    /// invalidated relayers are all unobserved — which is why the supervisor weighs it against a
865    /// cycle's whole remaining emission rather than a discounted one. The reserve subtracted is the
866    /// *effective* one already derived for this Session, not the configured ceiling.
867    pub(crate) fn drainable_surbs(&self) -> u64 {
868        self.estimator.saturating_diff().saturating_sub(self.min_surb_reserve)
869    }
870
871    /// Applies a rate planned by the PIX supervisor.
872    pub(crate) fn set_fill(&self, rate: FillRate) {
873        let mut state = self.state.lock();
874        if state.fill == rate {
875            return;
876        }
877        state.fill = rate;
878        self.apply(&state);
879    }
880
881    /// The rate the stream is currently running at — the faster of the two producers.
882    ///
883    /// The production path never asks: [`apply`](Self::apply) derives it and hands it straight to the
884    /// controller, which is the only consumer that matters. This exists so the merge rule can be
885    /// tested as the rule it is, rather than inferred from packet timings.
886    #[cfg(test)]
887    pub(crate) fn effective_rate(&self) -> FillRate {
888        Self::effective(&self.state.lock(), self.notify_period)
889    }
890
891    fn effective(state: &PixFillState, notify_period: Option<Duration>) -> FillRate {
892        let notify = state
893            .notify_started
894            .then_some(notify_period)
895            .flatten()
896            .map(FillRate::once_per);
897        let fill = (!state.fill.is_zero()).then_some(state.fill);
898        match (notify, fill) {
899            (Some(notify), Some(fill)) if fill.as_packets_per_sec() > notify.as_packets_per_sec() => fill,
900            (Some(notify), _) => notify,
901            (None, Some(fill)) => fill,
902            (None, None) => FillRate::ZERO,
903        }
904    }
905
906    fn apply(&self, state: &PixFillState) {
907        let rate = Self::effective(state, self.notify_period);
908        // A zero period would trip the controller's own assertion. `FillRate` never carries one —
909        // `ZERO` is one second and the two constructors take a validated period — so this is a
910        // belt-and-braces floor rather than a case that arises.
911        self.controller
912            .set_rate_per_unit(rate.packets as usize, rate.per.max(Duration::from_micros(1)));
913        trace!(
914            session_id = %self.session_id,
915            packets = rate.packets,
916            per = ?rate.per,
917            "applied the exit keep-alive rate"
918        );
919    }
920
921    /// Decides whether the next keep-alive may go out, and books it if so.
922    ///
923    /// The SURB-level notification always may: it is the message that asks the Entry for more SURBs,
924    /// so refusing it when SURBs are scarce is refusing to ask for the thing that is scarce.
925    ///
926    /// Fill above that rate is refused while the estimate is below the reserve, and that refusal is
927    /// not an optimisation. A return packet that finds no SURB is not dropped — the routing resolver
928    /// holds it, and with it every packet this node originates, for the whole of
929    /// `surb_resolution_wait`. An Exit filling a cycle down to its last SURB would therefore stall
930    /// the Session it is filling for, and every other Session on the node with it.
931    ///
932    /// # Which packet is the notification
933    ///
934    /// Nothing about a packet says which producer it belongs to, so the notification is the first one
935    /// released no sooner than a period after the last — *less a slack of
936    /// `min(MAX_WAIT_CHUNK, period / 2)`*, which is what keeps the classification from resting on
937    /// scheduling latency. The rate limiter that releases these packets sleeps in chunks of
938    /// [`MAX_WAIT_CHUNK`], re-reads its controller on each wake, and rounds a rate into an integer
939    /// packet count, so a stream asked for one packet per period releases them a shade over or a
940    /// shade under it. Compared against the period exactly, a period the controller rounds short by a
941    /// microsecond would classify every other notification as fill — counted in
942    /// `hopr_session_pix_fill_packets_total` and, below the reserve, dropped. That is the signal
943    /// asking the Entry for the SURBs whose absence caused the drop, and it runs on every Exit
944    /// Session with a `surb_balance_notify_period` configured, PIX or not.
945    ///
946    /// The slack cannot let fill masquerade as the notification: `last_notify_at` advances to the
947    /// instant the packet actually went out, so only the *first* packet past the slack window is the
948    /// notification, and every fill packet that follows it inside the period is still fill and is
949    /// still bounded by the reserve. The cap at half the period is what stops a very short period
950    /// from having its window widened away to nothing.
951    pub(crate) fn admit(&self) -> bool {
952        self.admit_at(Instant::now())
953    }
954
955    /// [`admit`](Self::admit) against a caller-supplied instant, so the classification can be tested
956    /// as the rule it is rather than by sleeping out a period and hoping the scheduler cooperates.
957    fn admit_at(&self, now: Instant) -> bool {
958        let mut state = self.state.lock();
959
960        // Due *slightly early*: see the classification rule on `admit`. The slack is what keeps this
961        // from resting on the scheduling latency of the stream that releases the packet.
962        let notification_due = state.notify_started
963            && self.notify_period.is_some_and(|period| {
964                let slack = MAX_WAIT_CHUNK.min(period / 2);
965                state
966                    .last_notify_at
967                    .is_none_or(|at| now.saturating_duration_since(at) >= period.saturating_sub(slack))
968            });
969
970        if notification_due {
971            state.last_notify_at = Some(now);
972            return true;
973        }
974
975        if self.estimator.saturating_diff() >= self.min_surb_reserve {
976            Self::set_withholding(&mut state, false);
977            #[cfg(feature = "telemetry")]
978            telemetry::record_pix_fill_packet();
979            return true;
980        }
981
982        debug!(
983            session_id = %self.session_id,
984            reserve = self.min_surb_reserve,
985            estimate = self.estimator.saturating_diff(),
986            "withholding a PIX fill keep-alive to stay above the SURB reserve"
987        );
988        Self::set_withholding(&mut state, true);
989        #[cfg(feature = "telemetry")]
990        telemetry::pix::record_pix_fill_backoff(telemetry::pix::PixFillBackoff::SurbReserve);
991        false
992    }
993
994    /// Moves the `surb_starved` gauge on the edges only, so it counts stalled Sessions rather than
995    /// refused packets — the same distinction `PixGateBlockEpisode` draws for the egress gate.
996    fn set_withholding(state: &mut PixFillState, withholding: bool) {
997        if state.withholding == withholding {
998            return;
999        }
1000        state.withholding = withholding;
1001        crate::supervision::telemetry::emit_sessions_stalled(
1002            crate::supervision::telemetry::PixStallCause::SurbStarved,
1003            if withholding { 1 } else { -1 },
1004        );
1005    }
1006}
1007
1008/// Returns this Session's `surb_starved` count if it was withholding when it went away.
1009///
1010/// `Drop` rather than a call on the close paths, for the reason `PixGateBlockEpisode` gives: a
1011/// Session can end by teardown, by drain, or by its last `Arc` going out of scope in a task nobody
1012/// is waiting on, and this is the one mechanism that covers all three. Without it a Session that
1013/// dies mid-stall leaves the gauge standing for the life of the process.
1014impl Drop for PixFillControl {
1015    fn drop(&mut self) {
1016        Self::set_withholding(self.state.get_mut(), false);
1017    }
1018}
1019
1020/// The SURB reserve one Session's fill actually respects.
1021///
1022/// [`PixFillConfig::min_surb_reserve`](crate::supervision::PixFillConfig::min_surb_reserve) is a
1023/// *ceiling* rather than the value used, and it has to be, because the two sides of the comparison
1024/// are chosen by different nodes. The reserve is measured against the Exit's estimate of its own SURB
1025/// buffer for the Session, and how deep that buffer is is the *Entry's* decision — it announces its
1026/// balancer target in the lower 32 bits of the options word in `StartInitiation::additional_data`. An operator sizing
1027/// the reserve against a production buffer therefore also decides, unintentionally, that every Session
1028/// whose Entry asks for a shallower one can never be filled at all: the estimate never reaches the
1029/// reserve, every fill packet is withheld, and the funded cycle strands on `max_recovery_time` while
1030/// the logs report only a backoff.
1031///
1032/// A quarter of the announced target is what the reserve is for expressed as a proportion: it leaves
1033/// fill three quarters of whatever buffer the Entry chose and keeps the last quarter for the
1034/// application, which is the side that has something waiting on it. The configured value still binds
1035/// wherever it is the smaller of the two — at the shipped 500 against the worked profile's 7 000-SURB
1036/// target, a quarter is 1 750, so the derivation is inert on a production-sized Session and only
1037/// engages on one whose Entry asked for less than four times the configured reserve.
1038///
1039/// Floored at one, for the same reason the constructor clamps: a zero reserve is fill spending the
1040/// Session's last SURB, and an Entry announcing a three-SURB buffer would otherwise produce exactly
1041/// that.
1042fn effective_surb_reserve(configured: u64, announced_target: u64) -> u64 {
1043    configured.min(announced_target / 4).max(1)
1044}
1045
1046/// One Session's reservation against the node's live reconstructor-cycle budget.
1047///
1048/// Charged when a PIX Session is accepted and returned when its Session closes. Deliberately a
1049/// projection rather than a measurement: the alternative is to weigh the reconstructor's actual
1050/// state and refuse a share once it is too large, which loses the cycle of whichever Session
1051/// happened to arrive last rather than of the one that inflated it. A reservation taken up front can
1052/// only ever refuse a Session that does not exist yet.
1053#[derive(Debug)]
1054pub(crate) struct CycleBudgetReservation {
1055    bytes: u64,
1056    outstanding: Arc<std::sync::atomic::AtomicU64>,
1057    /// Whether [`release`](Self::release) has already run, so it can be called from both the close
1058    /// path and `Drop` without the budget being returned twice.
1059    released: std::sync::atomic::AtomicBool,
1060}
1061
1062impl CycleBudgetReservation {
1063    /// Returns the reservation to the node's budget. Idempotent.
1064    ///
1065    /// Called explicitly by [`close_session`], and by `Drop` as the backstop.
1066    ///
1067    /// Both, and not just `Drop`, because the slot lives in a `moka` cache: `remove` hands the value
1068    /// back but drops the cache's own clone during a later maintenance pass, so a purely
1069    /// refcount-driven release would return the budget at an unpredictable time — and a node whose
1070    /// Sessions all closed could still refuse the next one. The explicit call makes the release
1071    /// simultaneous with the closure that caused it; the flag is what keeps the deferred drop from
1072    /// crediting it a second time.
1073    fn release(&self) {
1074        if self.released.swap(true, Ordering::Relaxed) {
1075            return;
1076        }
1077        // Saturating, so a release the flag somehow failed to suppress could not wrap the counter
1078        // into a budget that admits everything.
1079        let outstanding = self
1080            .outstanding
1081            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |held| {
1082                Some(held.saturating_sub(self.bytes))
1083            })
1084            .unwrap_or_default()
1085            .saturating_sub(self.bytes);
1086
1087        #[cfg(feature = "telemetry")]
1088        crate::telemetry::pix::record_cycle_bytes_released(self.bytes);
1089
1090        trace!(
1091            released = self.bytes,
1092            outstanding, "released live-cycle budget reservation"
1093        );
1094    }
1095}
1096
1097impl Drop for CycleBudgetReservation {
1098    fn drop(&mut self) {
1099        self.release();
1100    }
1101}
1102
1103/// RAII guard that rolls back a freshly inserted [`SessionSlot`] unless the
1104/// session setup is explicitly [committed](SessionSlotGuard::commit).
1105///
1106/// Establishing a session involves several fallible steps *after* the slot has
1107/// been inserted into the Session cache (constructing the [`HoprSession`],
1108/// notifying about the new session, sending the establishment message, ...).
1109/// If any of these steps fails, the already inserted slot would otherwise linger
1110/// in the cache until idle eviction, blocking the pseudonym (and counting towards
1111/// `maximum_sessions`) in the meantime.
1112///
1113/// Dropping this guard without committing removes the slot and tears down the
1114/// partially initialized session. Since Moka's removal is asynchronous and Rust
1115/// has no asynchronous `Drop`, the cleanup is performed on a spawned task.
1116struct SessionSlotGuard<'a> {
1117    sessions: &'a moka::sync::Cache<SessionId, SessionSlot>,
1118    active_sessions: Arc<std::sync::atomic::AtomicUsize>,
1119    session_id: SessionId,
1120    committed: bool,
1121}
1122
1123impl<'a> SessionSlotGuard<'a> {
1124    fn new(
1125        sessions: &'a moka::sync::Cache<SessionId, SessionSlot>,
1126        session_id: SessionId,
1127        active_sessions: Arc<std::sync::atomic::AtomicUsize>,
1128    ) -> Self {
1129        Self {
1130            sessions,
1131            active_sessions,
1132            session_id,
1133            committed: false,
1134        }
1135    }
1136
1137    /// Marks the session as successfully established, preventing the slot from
1138    /// being rolled back when this guard is dropped.
1139    ///
1140    /// Logs "new session established" here, on both the Entry and the Exit side, so that every
1141    /// "session closed" line from [`close_session`] has a matching start line.
1142    fn commit(&mut self) {
1143        self.committed = true;
1144        tracing::info!(session_id = %self.session_id, "new session established");
1145
1146        #[cfg(all(feature = "telemetry", not(test)))]
1147        METRIC_ACTIVE_SESSIONS.increment(1.0);
1148    }
1149}
1150
1151impl Drop for SessionSlotGuard<'_> {
1152    fn drop(&mut self) {
1153        if !self.committed {
1154            // The session setup failed after the slot was inserted: remove it so it does
1155            // not block the pseudonym until idle eviction.
1156            let session_id = self.session_id;
1157            warn!(%session_id, "rolling back partially established session slot after setup failure");
1158            if let Some(slot) = self.sessions.remove(&session_id) {
1159                self.active_sessions.fetch_sub(1, Ordering::Relaxed);
1160                // Not `close_session`: the Session was never established, so there is no
1161                // "session closed" line to pair with "new session established".
1162                release_session_slot(session_id, slot, ClosureReason::Eviction);
1163            }
1164        }
1165    }
1166}
1167
1168/// Why an inbound session data packet was dropped without being delivered.
1169///
1170/// All three are ordinary consequences of a session tearing down or being briefly overwhelmed, not
1171/// faults: a departing counterparty leaves packets in flight that arrive after its slot's sink is
1172/// gone or after the slot itself is deregistered. They are reported as a [`DispatchResult::Dropped`]
1173/// outcome rather than an `Err` precisely so the caller does not log them at `ERROR` — the loud
1174/// teardown-race spam this distinction exists to remove.
1175#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1176pub enum DropReason {
1177    /// The slot is still registered but its data sink (receiver) has been dropped
1178    /// (`TrySendError::Disconnected`). The common case when a session is being torn down.
1179    SinkClosed,
1180    /// The slot's inbox is full (`TrySendError::Full`): genuine backpressure, the reader is not
1181    /// keeping up. Distinct from [`SinkClosed`](Self::SinkClosed) because it is transient, not a
1182    /// teardown.
1183    SinkFull,
1184    /// No slot for this session id is registered (formerly the `UnknownData` error): the session
1185    /// was fully deregistered before this packet arrived.
1186    Unregistered,
1187}
1188
1189/// Indicates the result of processing a message.
1190#[derive(Clone, Debug, PartialEq, Eq)]
1191pub enum DispatchResult {
1192    /// Session or Start protocol message has been processed successfully.
1193    Processed,
1194    /// The message was not related to Start or Session protocol.
1195    Unrelated(ApplicationDataIn),
1196    /// The message belonged to a session but could not be delivered and was dropped. Benign — see
1197    /// [`DropReason`]. Reported as `Ok` so the caller logs it quietly instead of as an `ERROR`.
1198    Dropped(DropReason),
1199}
1200
1201/// Configuration of the PIX protocol for incoming Sessions on Exit nodes.
1202#[derive(Clone, Debug, PartialEq, smart_default::SmartDefault, serde::Serialize, serde::Deserialize)]
1203#[serde(default, deny_unknown_fields)]
1204pub struct IncomingSessionPixConfig {
1205    /// If set to true, incoming Session without the [`Capability::UsePIX`] will be rejected.
1206    ///
1207    /// Default `false`.
1208    #[default(false)]
1209    pub enforce_pix: bool,
1210    /// Acceptable range of data quota in bytes.
1211    ///
1212    /// With dynamic SSA batches enabled, the Exit selects the smallest batch up to
1213    /// [`SupervisorConfig::ssas_per_request`] whose total `batch_size × quota_per_ssa` is inside this
1214    /// range. With dynamic batches disabled, the Entry's per-SSA quota itself must be inside the
1215    /// range and the configured batch size is used exactly. An offer that satisfies neither rule is
1216    /// rejected before the Session is established.
1217    ///
1218    /// The default is derived from the default PIX dimensions
1219    /// ([`crate::DEFAULT_PIX_POLYS_PER_SSA`] × ([`crate::DEFAULT_PIX_SHARES_PER_POLY`] +
1220    /// [`crate::DEFAULT_PIX_SURPLUS_SHARES`])) rather than hard-coded, so that an Entry running the
1221    /// default configuration is always accepted. The upper bound is exactly
1222    /// [`DEFAULT_PIX_SSA_QUOTA`]. Since every dynamic batch has at least one member, that upper bound
1223    /// still caps the data served by any one SSA cycle. Accepting larger dimensions would raise both
1224    /// that exposure and the reconstructor memory held per Session, so an Exit that wants to serve
1225    /// them must widen this range explicitly.
1226    ///
1227    /// The quota it is compared against counts the surplus — `polys × (threshold + surplus) ×
1228    /// PIX_QUOTA_BYTES_PER_SHARE` — so this bounds the traffic actually served rather than the
1229    /// fraction of it the threshold accounts for. It used to bound only the latter, which understated
1230    /// the exposure by the surplus factor: 1.25× at the deployed dimensions.
1231    ///
1232    /// Defaults to `DEFAULT_PIX_SSA_QUOTA / 4 ..= DEFAULT_PIX_SSA_QUOTA`
1233    /// (≈ 227 MiB to ≈ 908 MiB, inclusive).
1234    #[default(_code = "DEFAULT_PIX_SSA_QUOTA / DEFAULT_PIX_QUOTA_RANGE_SPAN..=DEFAULT_PIX_SSA_QUOTA")]
1235    pub quota_range: std::ops::RangeInclusive<u64>,
1236    /// Ceiling on the live Exit-side reconstructor state this node will commit to, in bytes.
1237    ///
1238    /// **This, not [`SessionManagerConfig::maximum_sessions`], is what bounds reconstructor
1239    /// memory.** A PIX Session reserves `MAX_OVERLAPPING_BATCHES × ssas_per_request ×
1240    /// hopr_protocol_pix::peak_cycle_bytes(offered params)` when it is accepted and holds it until
1241    /// it closes; a Session that does not fit is refused with
1242    /// [`StartErrorReason::NoSlotsAvailable`] before any state is allocated for it. The reservation
1243    /// is computed from the parameters the *peer* offered, so a Session asking for smaller
1244    /// dimensions costs proportionally less of the budget.
1245    ///
1246    /// Enforced at admission rather than validated as a product of the configuration, for the reason
1247    /// `SsaReconstructorConfig::max_ack_buffer_bytes` gives: `maximum_sessions` validates to
1248    /// 100 000, and the resulting product is a number no node could hold, so validating it would
1249    /// only ever reject the shipping defaults. Counting what has actually been committed to is
1250    /// indifferent to how the ceiling was configured.
1251    ///
1252    /// Default is 3 GiB. Derived from the deployed operating point rather than picked: at the
1253    /// default dimensions a Session reserves ≈82 MiB, so this admits ≈37 concurrent PIX Sessions,
1254    /// comfortably covering the 10–30 clients per Exit the calibration profile models. The same
1255    /// defaults with `maximum_sessions = 100` and no budget imply ≈8 GiB.
1256    ///
1257    /// A ceiling on *reservations*, not an allocation: nothing is claimed up front, and because the
1258    /// reservation is denominated at the adversarial peak, a node serving conforming peers holds an
1259    /// order of magnitude less than it has reserved. The number exists to stop an Exit selling more
1260    /// service than its memory can hold, not to describe what it will typically use.
1261    #[default(3 * 1024 * 1024 * 1024)]
1262    pub max_live_cycle_bytes: u64,
1263    /// Deadlines, fault limits and service budget the Exit-side PIX supervisor enforces on a
1264    /// Session.
1265    ///
1266    /// These live together rather than spread across this struct because they are only meaningful
1267    /// as a set: [`crate::validate_pix_supervision`] checks them against each other and against the
1268    /// reconstructor's lifetimes, and the node's config validator runs that at load time.
1269    pub supervision: SupervisorConfig,
1270}
1271
1272impl IncomingSessionPixConfig {
1273    /// The terms one Session is judged on, once the session server has had its say.
1274    ///
1275    /// The configured [`quota_range`](Self::quota_range) is an envelope, not a default: it is what
1276    /// [`validate_incoming_session_pix_config`] checked the deadlines and memory ceiling against,
1277    /// and what [`start_protocol_channel_capacity`] sized a preallocated ring from, both once at
1278    /// startup. So a decision's range is *intersected* with it rather than replacing it — a Session
1279    /// may be held to less than the node advertises, never to more than it was configured to
1280    /// honour. An operator wanting a wider class configures a wider envelope and narrows the rest.
1281    ///
1282    /// [`enforce_pix`](Self::enforce_pix) carries no such coupling and is simply overridden.
1283    fn effective_for(&self, decision: &SessionAdmissionDecision) -> EffectivePixAdmission {
1284        let quota_range = match &decision.pix_quota_range {
1285            Some(narrowed) => {
1286                *self.quota_range.start().max(narrowed.start())..=*self.quota_range.end().min(narrowed.end())
1287            }
1288            None => self.quota_range.clone(),
1289        };
1290
1291        EffectivePixAdmission {
1292            enforce_pix: decision.enforce_pix.unwrap_or(self.enforce_pix),
1293            quota_range,
1294            allow_dynamic_ssa_batches: self.supervision.allow_dynamic_ssa_batches,
1295            ssas_per_request: self.supervision.ssas_per_request,
1296        }
1297    }
1298
1299    /// The supervisor configuration for Sessions accepted under these settings.
1300    pub fn supervisor_config(&self) -> SupervisorConfig {
1301        self.supervision.clone()
1302    }
1303}
1304
1305/// The PIX terms a single incoming Session is admitted on.
1306///
1307/// Lighter than the whole [`IncomingSessionPixConfig`] because only these four fields vary per
1308/// Session: everything else the supervisor enforces is validated as a set at startup and stays
1309/// node-wide. Narrowing the quota only makes those deadlines more generous, which is why they need
1310/// not travel with it.
1311#[derive(Clone, Debug, PartialEq)]
1312pub(crate) struct EffectivePixAdmission {
1313    /// Whether this Session must offer PIX at all.
1314    enforce_pix: bool,
1315    /// Quota window this Session's offer must land in, already intersected with the node's envelope.
1316    quota_range: std::ops::RangeInclusive<SsaQuota>,
1317    /// Whether a batch may be derived from the offer, rather than taken as configured.
1318    allow_dynamic_ssa_batches: bool,
1319    /// Largest batch the Exit will ask for.
1320    ssas_per_request: usize,
1321}
1322
1323impl EffectivePixAdmission {
1324    /// Selects the batch size with which an Entry's per-SSA quota is acceptable.
1325    ///
1326    /// Dynamic mode walks from one upwards so it asks for no more Entry work, deposits or Exit
1327    /// reconstructor state than necessary. Fixed mode is the legacy rule: validate one SSA against
1328    /// the range, then request exactly the configured batch size.
1329    fn ssa_batch_size_for_quota(&self, quota_per_ssa: SsaQuota) -> Option<usize> {
1330        let configured_batch = self.ssas_per_request.clamp(1, MAX_SSA_BATCH_SIZE);
1331
1332        if !self.allow_dynamic_ssa_batches {
1333            return self.quota_range.contains(&quota_per_ssa).then_some(configured_batch);
1334        }
1335
1336        (1..=configured_batch).find(|batch_size| {
1337            quota_per_ssa
1338                .checked_mul(*batch_size as u64)
1339                .is_some_and(|batch_quota| self.quota_range.contains(&batch_quota))
1340        })
1341    }
1342}
1343
1344/// Bit position of the 64-bit options word within `StartInitiation::additional_data`.
1345///
1346/// The options word (PIX parameters and SURB balancer target) occupies the most significant 64 bits;
1347/// the least significant 64 bits are currently unused and always sent as `0`.
1348const INITIATION_OPTIONS_SHIFT: u32 = u64::BITS;
1349
1350/// Places the 64-bit options word into the most significant half of `StartInitiation::additional_data`,
1351/// leaving the (currently unused) least significant half zero.
1352#[inline]
1353const fn into_initiation_additional_data(options: u64) -> u128 {
1354    (options as u128) << INITIATION_OPTIONS_SHIFT
1355}
1356
1357/// Inverse of [`into_initiation_additional_data`], ignoring the least significant half.
1358#[inline]
1359const fn initiation_options(additional_data: u128) -> u64 {
1360    (additional_data >> INITIATION_OPTIONS_SHIFT) as u64
1361}
1362
1363/// Decodes the incentivization the Entry offered, before anyone is asked what to do about it.
1364///
1365/// `Ok(None)` is a peer that offered none. `Err` is one whose offer this node cannot read or
1366/// cannot share a curve with, which is refused on its own — no policy could rescue it, and both
1367/// questions are cheaper than the round trip to the session server that follows.
1368///
1369/// Split out from `SessionManager::check_pix_params` so the session server can be
1370/// told what was offered: it decides the terms, and terms relative to an offer need the offer.
1371fn decode_pix_offer(
1372    req: &StartInitiation<SessionTarget, HoprSessionCapabilities>,
1373) -> Result<Option<PixParams>, StartErrorReason> {
1374    if !req.capabilities.0.contains(Capability::UsePIX) {
1375        return Ok(None);
1376    }
1377
1378    // Unpacking is what enforces the protocol ranges on the three dimensions, and what rejects a
1379    // suite identifier no curve claims — leaving only "a known curve, but not ours" below.
1380    let params = PixParams::try_from_additional_data(initiation_options(req.additional_data)).map_err(|error| {
1381        debug!(
1382            challenge = req.challenge,
1383            %error,
1384            "client offered PIX parameters outside the protocol ranges"
1385        );
1386        StartErrorReason::UnacceptablePixParams
1387    })?;
1388
1389    // The Exit decides the curve, and it decides it by refusing anything else: nothing here
1390    // is negotiated, because there is nothing to negotiate — the suite is fixed at build
1391    // time on both sides. Checked before the quota because it is the cheaper question and
1392    // because a suite mismatch makes the dimensions meaningless anyway, and checked at all
1393    // because every later PIX field is sized by it. Refusing here means the Exit's own
1394    // commitments, the first curve-sized bytes in the exchange, are never sent to a peer
1395    // that would read their boundaries in the wrong place.
1396    if params.suite() != LOCAL_PIX_SUITE {
1397        warn!(
1398            challenge = req.challenge,
1399            offered = %params.suite(),
1400            ours = %LOCAL_PIX_SUITE,
1401            "refusing a client offering a PIX curve suite this node was not built for"
1402        );
1403        return Err(StartErrorReason::UnacceptablePixParams);
1404    }
1405
1406    Ok(Some(params))
1407}
1408
1409/// Validates the incoming-PIX invariants that span quota, supervision and memory settings.
1410///
1411/// `max_session_packet_rate` is the fastest a Session is assumed to go. Without fill it bounds
1412/// `max_recovery_time` directly; with fill it caps the `fill.max_rate` the deadline may count on.
1413pub fn validate_incoming_session_pix_config(
1414    cfg: &IncomingSessionPixConfig,
1415    max_session_packet_rate: u64,
1416) -> errors::Result<()> {
1417    if cfg.quota_range.is_empty() {
1418        return Err(TransportSessionError::InvalidConfig(
1419            "PIX quota_range must be non-empty (start must not exceed end)".into(),
1420        ));
1421    }
1422    if max_session_packet_rate == 0 {
1423        return Err(TransportSessionError::InvalidConfig(
1424            "maximum assumed PIX Session packet rate must be non-zero".into(),
1425        ));
1426    }
1427    // Named here although `validate_pix_supervision` also refuses it, because that runs later on both
1428    // paths: with fill enabled a zero deadline would otherwise surface as an infinite fill rate.
1429    if cfg.supervision.max_recovery_time.is_zero() {
1430        return Err(TransportSessionError::InvalidConfig(
1431            "PIX max_recovery_time must be non-zero".into(),
1432        ));
1433    }
1434
1435    let worst_cycle_packets = *cfg.quota_range.end() / PIX_QUOTA_BYTES_PER_SHARE;
1436
1437    // `max_recovery_time` has to leave a cycle of the widest accepted quota time to complete, and what
1438    // completes it depends on fill. With fill enabled the Exit finishes an under-served cycle itself, so
1439    // the bound is the fill ceiling's own arithmetic below. Without it only application traffic does,
1440    // and the Exit has no rate of its own to reason from.
1441    if cfg.supervision.fill.enabled {
1442        // The fill ceiling has to clear the rate a cycle of the widest accepted quota actually needs,
1443        // or the mechanism that exists to complete idle cycles cannot complete the very ones it
1444        // admits. The arithmetic is the planner's own, taken at the moment fill starts: the whole
1445        // cycle remains, the aim point sits `finish_fraction` of the way through `max_recovery_time`,
1446        // and the loss margin is paid on top. `max_recovery_time` is the *configured* per-cycle budget
1447        // rather than a deadline observed at run time, so this is a property of the configuration and
1448        // can be decided here.
1449        //
1450        // The ceiling counts only up to `max_session_packet_rate`: fill faster than any Session is
1451        // assumed to carry cannot finish a cycle any sooner.
1452        let fill = &cfg.supervision.fill;
1453        // Before the arithmetic, not after it. `validate_pix_supervision` also range-checks these two,
1454        // but it runs later on both paths that reach here — `SessionManager::start` calls this first,
1455        // and in `HoprProtocolConfig` this is a field validator while that is a schema-level one. The
1456        // `mul_f64` below panics on a `NaN` or negative fraction, so an unchecked one aborts the node
1457        // at config load rather than being reported; a fraction above one silently lowers the floor.
1458        crate::supervision::validate_fill_fractions(fill)?;
1459
1460        let horizon = cfg.supervision.max_recovery_time.mul_f64(fill.finish_fraction);
1461        // Still guarded although both factors are non-zero: a nanosecond-scale deadline times a
1462        // fraction rounds to zero, and dividing by it would report an infinite requirement.
1463        let floor = if horizon.is_zero() {
1464            u64::MAX
1465        } else {
1466            (worst_cycle_packets as f64 * (1.0 + fill.loss_margin) / horizon.as_secs_f64()).ceil() as u64
1467        };
1468        if (fill.max_rate as u64).min(max_session_packet_rate) < floor {
1469            let remedy = if floor > max_session_packet_rate {
1470                "raise max_recovery_time"
1471            } else {
1472                "raise fill.max_rate or max_recovery_time"
1473            };
1474            return Err(TransportSessionError::InvalidConfig(format!(
1475                "PIX fill.max_rate is {} (counted up to {max_session_packet_rate} packets/s), but the largest \
1476                 accepted quota ({} bytes) is {worst_cycle_packets} packets and needs at least {floor} packets/s to \
1477                 finish inside {horizon:?} with a {} loss margin; {remedy}",
1478                fill.max_rate,
1479                cfg.quota_range.end(),
1480                fill.loss_margin,
1481            )));
1482        }
1483    } else {
1484        // Refused only if even a Session at the fastest assumed rate could not finish a cycle in time,
1485        // so this rejects a deadline that is impossible rather than one that is merely tight.
1486        let needed = Duration::from_secs(worst_cycle_packets.div_ceil(max_session_packet_rate));
1487        if cfg.supervision.max_recovery_time < needed {
1488            return Err(TransportSessionError::InvalidConfig(format!(
1489                "PIX max_recovery_time is {:?}, but with fill disabled the largest accepted quota ({} bytes) is \
1490                 {worst_cycle_packets} packets and needs at least {needed:?} even at {max_session_packet_rate} \
1491                 packets/s",
1492                cfg.supervision.max_recovery_time,
1493                cfg.quota_range.end(),
1494            )));
1495        }
1496    }
1497
1498    let widest = max_cycle_budget_for_quota(*cfg.quota_range.end(), cfg.supervision.ssas_per_request);
1499    if cfg.max_live_cycle_bytes < widest {
1500        return Err(TransportSessionError::InvalidConfig(format!(
1501            "PIX max_live_cycle_bytes is {}, but one Session offering the largest accepted quota ({} bytes) reserves \
1502             up to {widest} bytes of reconstructor state",
1503            cfg.max_live_cycle_bytes,
1504            cfg.quota_range.end(),
1505        )));
1506    }
1507
1508    Ok(())
1509}
1510
1511/// Configuration for the [`SessionManager`].
1512#[derive(Clone, Debug, PartialEq, smart_default::SmartDefault)]
1513pub struct SessionManagerConfig {
1514    /// The maximum chunk of data that can be written to the Session's input buffer.
1515    ///
1516    /// Clamped to the socket's supported frame size range, preserving boundaries within that range.
1517    ///
1518    /// Default is [`SESSION_MTU`], i.e. exactly one segment per frame.
1519    #[default(SESSION_MTU)]
1520    pub frame_mtu: usize,
1521
1522    /// The maximum time for an incomplete frame to stay in the Session's output buffer.
1523    ///
1524    /// Must exceed the worst-case SURB replenishment latency on the return path.
1525    /// The SURB balancer replenishes via KeepAlive every ~2 s; the retry loop in
1526    /// the routing resolver sleeps 5 ms per attempt and can block for the full
1527    /// replenishment cycle.  Setting this below the KeepAlive period causes the
1528    /// sequencer to discard frames that arrive after a SURB-starved peer finally
1529    /// gets new SURBs and sends its echo.
1530    ///
1531    /// Default is 3 s.
1532    #[default(Duration::from_secs(3))]
1533    pub max_frame_timeout: Duration,
1534
1535    /// Maximum number of segments to buffer in the downstream transport of a Session's socket.
1536    /// If 0 is given, the transport is unbuffered.
1537    ///
1538    /// Default is 0.
1539    #[default(0)]
1540    pub max_buffered_segments: usize,
1541
1542    /// Abandon the frame due next once this many later frames are waiting behind it, rather than
1543    /// holding them for the whole of [`Self::max_frame_timeout`].
1544    ///
1545    /// The two answer different questions. `max_frame_timeout` is how long a *missing* frame is
1546    /// waited for, and is set here to exceed the ~2 s SURB KeepAlive so a starved peer's late echo
1547    /// is not discarded. This bounds how much *already received* data is held hostage during that
1548    /// wait -- a cost paid once per gap, which compounds as later frames queue up.
1549    ///
1550    /// Without it, a session that cannot retransmit waits the full timeout for a frame that will
1551    /// never arrive. Measured on a 5-node cluster after killing a return relayer: 98.5 % of bytes
1552    /// returned over the wire, 0.60 % reached the application, and the application-side
1553    /// inter-arrival median sat exactly on the 3 s timeout.
1554    ///
1555    /// The right value tracks reordering depth -- throughput x latency spread / frame size -- so
1556    /// it is deployment-specific. Tune with `SessionConfig::max_frames_behind_gap`; too low converts
1557    /// ordinary reordering into loss, too high leaves the stall in place.
1558    ///
1559    /// Default is 256, which is roughly 3-4x the reordering depth of the cluster it was measured
1560    /// on (~0.5 MB/s over paths spread across 10-260 ms, 1500 B frames, so ~70-85 frames in
1561    /// flight out of order). The margin is not cosmetic: at 64 -- about one reordering depth --
1562    /// a *healthy* baseline dropped from 100 % to 92.2 % arrival, because ordinary reordering was
1563    /// being read as loss. At 256 the same baseline returned 100 % while a return relayer killed
1564    /// mid-session still recovered 97.1 %, against 0.60 % with the bound absent.
1565    ///
1566    /// Default is 256.
1567    #[default(Some(256))]
1568    pub max_frames_behind_gap: Option<usize>,
1569
1570    /// The base timeout for initiation of Session initiation.
1571    ///
1572    /// The actual timeout is adjusted according to the number of hops for that Session:
1573    /// `t = initiation_time_out_base * (num_forward_hops + num_return_hops + 2)`
1574    ///
1575    /// Default is 500 milliseconds.
1576    #[default(Duration::from_millis(500))]
1577    pub initiation_timeout_base: Duration,
1578
1579    /// Timeout for Session to be closed due to inactivity.
1580    ///
1581    /// Default is 180 seconds.
1582    #[default(Duration::from_secs(180))]
1583    pub idle_timeout: Duration,
1584
1585    /// Minimum interval at which an establishing Session's cache slot is refreshed
1586    /// ("touched") to keep [`idle_timeout`](Self::idle_timeout) from evicting it while
1587    /// SURBs pre-load.
1588    ///
1589    /// The touch period is [`idle_timeout`](Self::idle_timeout)` / 2`, floored to this
1590    /// value so it never drops below a sane minimum for very short idle timeouts.
1591    ///
1592    /// Default is 100 milliseconds.
1593    #[default(Duration::from_millis(100))]
1594    pub min_session_touch_period: Duration,
1595
1596    /// The sampling interval for SURB balancer.
1597    /// It will make SURB control decisions regularly at this interval.
1598    ///
1599    /// Default is 100 milliseconds.
1600    #[default(Duration::from_millis(100))]
1601    pub balancer_sampling_interval: Duration,
1602
1603    /// Initial packets per second egress rate on an incoming Session.
1604    ///
1605    /// This only applies to incoming Sessions without the [`Capability::NoRateControl`] flag set.
1606    ///
1607    /// Default is 10 packets/second.
1608    #[default(10)]
1609    pub initial_return_session_egress_rate: usize,
1610
1611    /// Minimum period of time for which a SURB buffer at the Exit must
1612    /// endure if no SURBs are being received.
1613    ///
1614    /// In other words, it is the minimum period of time an Exit must withstand when
1615    /// no SURBs are received from the Entry at all. To do so, the egress traffic
1616    /// will be shaped accordingly to meet this requirement.
1617    ///
1618    /// This only applies to incoming Sessions without the [`Capability::NoRateControl`] flag set.
1619    ///
1620    /// Default is 5 seconds, minimum is 1 second.
1621    #[default(Duration::from_secs(5))]
1622    pub minimum_surb_buffer_duration: Duration,
1623
1624    /// Indicates the maximum number of SURBs in the SURB buffer to be requested when creating a new Session.
1625    ///
1626    /// This value is theoretically capped by the size of the global transport SURB ring buffer,
1627    /// so values greater than that do not make sense. This value should be ideally set equal
1628    /// to the size of the global transport SURB RB.
1629    ///
1630    /// Default is 10 000 SURBs.
1631    #[default(10_000)]
1632    pub maximum_surb_buffer_size: usize,
1633
1634    /// If set, the Session recipient (Exit) will notify the Session initiator (Entry) about
1635    /// its SURB balance for the Session using keep-alive packets periodically.
1636    ///
1637    /// Keep in mind that each notification also costs 1 SURB, so the notification period should
1638    /// not be too frequent.
1639    ///
1640    /// Default is None (no notification sent to the client), minimum is 1 second.
1641    #[default(None)]
1642    pub surb_balance_notify_period: Option<Duration>,
1643
1644    /// If set, the Session initiator (Entry) will notify the Session recipient (Exit) about
1645    /// the local SURB balancer target using keep-alive packets from the SURB balancer.
1646    ///
1647    /// This is useful when the client plans to change the SURB balancer target dynamically.
1648    ///
1649    /// Default is true.
1650    #[default(true)]
1651    pub surb_target_notify: bool,
1652
1653    /// Maximum number of concurrent sessions allowed.
1654    ///
1655    /// Default is 10_000.
1656    #[default(10_000)]
1657    pub maximum_sessions: usize,
1658
1659    /// How many packets can be buffered if the [`HoprSession`] input socket is not fast enough.
1660    ///
1661    /// Controls the capacity of the internal `crossfire` channel used for each session slot.
1662    ///
1663    /// Default is 10 000.
1664    #[default(10000)]
1665    pub session_forward_capacity: usize,
1666
1667    /// Configuration of the PIX protocol for the Exit nodes.
1668    pub pix_config: IncomingSessionPixConfig,
1669
1670    /// Maximum number of SSA commitments this node, acting as an Entry, will accept in a single
1671    /// [`SsaServerCommitmentMessage`].
1672    ///
1673    /// This is the Entry's protection against a misbehaving Exit, not a preference: every accepted
1674    /// entry costs a full `new_ssa_commitment`, thousands of outbound `SsaCommit` packets and its own
1675    /// on-chain deposit, so without a cap one inbound packet amplifies into minutes of CPU, a large
1676    /// packet burst, and as many simultaneous deposits as the wire format admits. An over-cap request
1677    /// is rejected in full, before any commitment is generated or any `ReadyToDeposit` is emitted, and
1678    /// the Exit is told with a `SessionError` — see `refuse_ssa_request`.
1679    ///
1680    /// It must be at least the `ssas_per_request` of every Exit this node connects to — see
1681    /// [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request) for why a mismatch
1682    /// loses every Session.
1683    ///
1684    /// Clamped to `1..=`[`MAX_SSA_BATCH_SIZE`] in [`SessionManager::new`].
1685    ///
1686    /// Defaults to [`DEFAULT_MAX_SSAS_PER_SSA_REQUEST`] (2).
1687    #[default(DEFAULT_MAX_SSAS_PER_SSA_REQUEST)]
1688    pub max_ssas_per_ssa_request: usize,
1689
1690    /// How long the Exit waits for the session server to say whether a Session is admitted.
1691    ///
1692    /// The wait sits inside the peer's own initiation timeout, so it has to be comfortably shorter
1693    /// than [`initiation_timeout_base`](Self::initiation_timeout_base): a server slower than the
1694    /// peer is patient refuses every Session it is asked about, and does so after the peer has
1695    /// already given up. [`SessionManager::new`] rejects a configuration where it is not.
1696    ///
1697    /// Expiring refuses the Session — the alternative, admitting on the node's own terms, would let
1698    /// an overloaded or wedged server silently disable every rule the operator wrote.
1699    ///
1700    /// Default is 2 seconds.
1701    #[default(Duration::from_secs(2))]
1702    pub session_admission_timeout: Duration,
1703}
1704
1705// Type-erased sink used by the `SessionManager` to notify about newly incoming sessions.
1706// The errors produced by the underlying sink are remapped into `SessionManagerError`.
1707type BoxSink<T> = Pin<Box<dyn Sink<T, Error = SessionManagerError> + Send>>;
1708
1709type SessionNotifiers = (
1710    Arc<hopr_utils::runtime::prelude::Mutex<BoxSink<IncomingSession>>>,
1711    crossfire::MTx<crossfire::mpsc::Array<(SessionId, ClosureReason)>>,
1712);
1713
1714// Sink for processing Start protocol messages.
1715// Must be within Arc to be shared across SessionManager clones.
1716// The inner OnceLock is set once in `start()` and read in `dispatch_message`.
1717type StartProtocolMsgSink = Arc<OnceLock<crossfire::MTx<crossfire::mpsc::Array<(HoprPseudonym, HoprStartProtocol)>>>>;
1718
1719/// PIX protocol toolbox to enable [`SessionManager`] to use PIX protocol.
1720#[derive(Clone)]
1721pub struct PixToolbox {
1722    share_generator: Arc<SsaShareGenerator<HoprPixSpec>>,
1723    share_processor: Arc<SsaReconstructor<HoprPixSpec>>,
1724    pix_events: crossfire::MTx<crossfire::mpsc::Array<HoprSessionOutPixEvent>>,
1725}
1726
1727impl PixToolbox {
1728    pub fn new(
1729        share_generator: Arc<SsaShareGenerator<HoprPixSpec>>,
1730        share_processor: Arc<SsaReconstructor<HoprPixSpec>>,
1731    ) -> (Self, impl futures::Stream<Item = HoprSessionOutPixEvent>) {
1732        let (pix_events, pix_events_rx) = crossfire::mpsc::bounded_blocking_async::<HoprSessionOutPixEvent>(1024);
1733        (
1734            Self {
1735                share_generator,
1736                share_processor,
1737                pix_events,
1738            },
1739            pix_events_rx.into_stream(),
1740        )
1741    }
1742}
1743
1744/// Manages lifecycles of Sessions.
1745///
1746/// Once the manager is [started](SessionManager::start), the [`SessionManager::dispatch_message`]
1747/// should be called for each [`ApplicationData`] received by the node.
1748/// This way, the `SessionManager` takes care of proper Start sub-protocol message processing
1749/// and correct dispatch of Session-related packets to individual existing Sessions.
1750///
1751/// Secondly, the manager can initiate new outgoing sessions via [`SessionManager::new_session`],
1752/// probe sessions using [`SessionManager::ping_session`]
1753/// and list them via [`SessionManager::active_sessions`].
1754///
1755/// Since the `SessionManager` operates over the HOPR protocol,
1756/// the message transport `S` is required.
1757/// Such transport must also be `Clone`, since it will be cloned into all the created [`HoprSession`] objects.
1758///
1759/// ## SURB balancing
1760///
1761/// The manager also can take care of automatic [SURB balancing](SurbBalancerConfig) per Session.
1762///
1763/// With each packet sent from the session initiator over to the receiving party, zero to 2 SURBs might be delivered.
1764/// When the receiving party wants to send reply packets back, it must consume 1 SURB per packet. This
1765/// means that if the difference between the SURBs delivered and SURBs consumed is negative, the receiving party
1766/// might soon run out of SURBs. If SURBs run out, the reply packets will be dropped, causing likely quality of
1767/// service degradation.
1768///
1769/// In an attempt to counter this effect, there are two co-existing automated modes of SURB balancing:
1770/// *local SURB balancing* and *remote SURB balancing*.
1771///
1772/// ### Local SURB balancing
1773///
1774/// Local SURB balancing is performed on the sessions that were initiated by another party (and are
1775/// therefore incoming to us).
1776/// The local SURB balancing mechanism continuously evaluates the rate of SURB consumption and retrieval,
1777/// and if SURBs are running out, the packet egress shaping takes effect. This by itself does not
1778/// avoid the depletion of SURBs but slows it down in the hope that the initiating party can deliver
1779/// more SURBs over time. This might happen either organically by sending effective payloads that
1780/// allow non-zero number of SURBs in the packet, or non-organically by delivering KeepAlive messages
1781/// via *remote SURB balancing*. Both are governed by the initiator's estimate of our buffer, so
1782/// signalling SURB distress is what tells it to keep the organic ones coming.
1783///
1784/// The egress shaping is done automatically, unless the Session initiator sets the [`Capability::NoRateControl`]
1785/// flag during Session initiation.
1786///
1787/// ### Remote SURB balancing
1788///
1789/// Remote SURB balancing is performed by the Session initiator. The SURB balancer estimates the number of SURBs
1790/// delivered to the other party, and also the number of SURBs consumed by seeing the amount of traffic received
1791/// in replies.
1792/// When enabled, a desired target level of SURBs at the Session counterparty is set. According to measured
1793/// inflow and outflow of SURBs to/from the counterparty, the production of non-organic SURBs is started
1794/// via keep-alive messages (sent to counterparty) and is controlled to maintain that target level.
1795///
1796/// In other words, the Session initiator tries to compensate for the usage of SURBs by the counterparty by
1797/// sending new ones via the keep-alive messages.
1798///
1799/// The same estimate also governs *organic* production: once the counterparty is estimated to be at
1800/// its target, outgoing Session data packets carry no SURBs at all, and they return to one per packet
1801/// as soon as the estimate falls back below target. Without that, a balancer could correctly silence
1802/// its keep-alives while organic SURBs kept pouring into a full buffer — where each one evicts an
1803/// older SURB and destroys the PIX share it was carrying. A `SurbDistress` or `OutOfSurbs` signal
1804/// from the counterparty overrides the estimate and restores production immediately, which is what
1805/// makes it safe to act on an estimate that forward-path loss can inflate.
1806///
1807/// This mechanism is configurable via the `surb_management` field in [`SessionClientConfig`].
1808///
1809/// ### Possible scenarios
1810///
1811/// There are 4 different scenarios of local vs. remote SURB balancing configuration, but
1812/// an equilibrium (= matching the SURB production and consumption) is most likely to be reached
1813/// only when both are configured (the ideal case below):
1814///
1815/// #### 1. Ideal local and remote SURB balancing
1816///
1817/// 1. The Session recipient (Exit) set the `initial_return_session_egress_rate`, `max_surb_buffer_duration` and
1818///    `maximum_surb_buffer_size` values in the [`SessionManagerConfig`].
1819/// 2. The Session initiator (Entry) sets the [`target_surb_buffer_size`](SurbBalancerConfig) which matches the
1820///    [`maximum_surb_buffer_size`](SessionManagerConfig) of the counterparty.
1821/// 3. The Session initiator (Entry) does *NOT* set the [`Capability::NoRateControl`] capability flag when opening
1822///    Session.
1823/// 4. The Session initiator (Entry) sets [`max_surbs_per_sec`](SurbBalancerConfig) slightly higher than the
1824///    `maximum_surb_buffer_size / max_surb_buffer_duration` value configured at the counterparty.
1825///
1826/// In this situation, the maximum Session egress from Exit to the Entry is given by the
1827/// `maximum_surb_buffer_size / max_surb_buffer_duration` ratio. If there is enough bandwidth,
1828/// the (remote) SURB balancer sending SURBs to the Exit will stabilize roughly at this rate of SURBs/sec,
1829/// and the whole system will be in equilibrium during the Session's lifetime (under ideal network conditions).
1830///
1831/// #### 2. Remote SURB balancing only
1832///
1833/// 1. The Session initiator (Entry) *DOES* set the [`Capability::NoRateControl`] capability flag when opening Session.
1834/// 2. The Session initiator (Entry) sets `max_surbs_per_sec` and `target_surb_buffer_size` values in
1835///    [`SurbBalancerConfig`]
1836///
1837/// In this one-sided situation, the Entry node floods the Exit node with SURBs,
1838/// only based on its estimated consumption of SURBs at the Exit. The Exit's egress is not
1839/// rate-limited at all. If the Exit runs out of SURBs at any point in time, it will simply drop egress packets.
1840///
1841/// This configuration could potentially only lead to an equilibrium
1842/// when the `SurbBalancer` at the Entry can react fast enough to Exit's demand.
1843///
1844/// #### 3. Local SURB balancing only
1845///
1846/// 1. The Session recipient (Exit) set the `initial_return_session_egress_rate`, `max_surb_buffer_duration` and
1847///    `maximum_surb_buffer_size` values in the [`SessionManagerConfig`].
1848/// 2. The Session initiator (Entry) does *NOT* set the [`Capability::NoRateControl`] capability flag when opening
1849///    Session.
1850/// 3. The Session initiator (Entry) does *NOT* set the [`SurbBalancerConfig`] at all when opening Session.
1851///
1852/// In this one-sided situation, the Entry node does not provide any additional SURBs at all (except the
1853/// ones that are naturally carried by the egress packets which have space to hold SURBs). It relies
1854/// only on the Session egress limiting of the Exit node.
1855/// The Exit will limit the egress roughly to the rate of natural SURB occurrence in the ingress.
1856///
1857/// This configuration could potentially only lead to an equilibrium when uploading non-full packets
1858/// (ones that can carry at least a single SURB), and the Exit's egress is limiting itself to such a rate.
1859/// If Exit's egress reaches low values due to SURB scarcity, the upper layer protocols over Session might break.
1860///
1861/// #### 4. No SURB balancing on each side
1862///
1863/// 1. The Session initiator (Entry) *DOES* set the [`Capability::NoRateControl`] capability flag when opening Session.
1864/// 2. The Session initiator (Entry) does *NOT* set the [`SurbBalancerConfig`] at all when opening Session.
1865///
1866/// In this situation, no additional SURBs are being produced by the Entry and no Session egress rate-limiting
1867/// takes place at the Exit.
1868///
1869/// This configuration can only lead to an equilibrium when Entry sends non-full packets (ones that carry
1870/// at least a single SURB) and the Exit is consuming the SURBs (Session egress) at a slower or equal rate.
1871/// Such configuration is very fragile, as any disturbances in the SURB flow might lead to a packet drop
1872/// at the Exit's egress.
1873///
1874/// ### SURB decay
1875///
1876/// In a hypothetical scenario of a non-zero packet loss, the Session initiator (Entry) might send a
1877/// certain number of SURBs to the Session recipient (Exit), but only a portion of it is actually delivered.
1878/// The Entry has no way of knowing that and assumes that everything has been delivered.
1879/// A similar problem happens when the Exit uses SURBs to construct return packets, but only a portion
1880/// of those packets is actually delivered to the Entry. At this point, the Entry also subtracts
1881/// fewer SURBs from its SURB estimate at the Exit.
1882///
1883/// In both situations, the Entry thinks there are more SURBs available at the Exit than there really are.
1884///
1885/// To compensate for a potential packet loss, the Entry's estimation of Exit's SURB buffer is regularly
1886/// diminished by a percentage of the `target_surb_buffer_size`, even if no incoming traffic from the
1887/// Exit is detected.
1888///
1889/// This behavior can be controlled via the `surb_decay` field of [`SurbBalancerConfig`].
1890///
1891/// ### SURB balance and target notification
1892///
1893/// The Session recipient (Exit) can notify the Session initiator (Entry) periodically about its estimated
1894/// number of SURBs for the Session. This can help the Entry to adjust its approximation of that level so
1895/// that its Local SURB balancer can better intervene.
1896/// This can be set using the `surb_balance_notify_period` field of [`SessionManagerConfig`] for the Exit.
1897///
1898/// Likewise, the Entry can inform the Exit about its desired SURB buffer target so that the Exit
1899/// can better accommodate its Remote SURB balancing.
1900/// This can be set using the `surb_target_notify` field of the [`SessionManagerConfig`] of each new Session.
1901///
1902/// Both mechanisms leverage the Keep Alive message to report the respective values.
1903///
1904/// ## PIX (Protocol for Incentivization of eXits) Protocol Flow
1905///
1906/// When a Session is opened with the [`Capability::UsePIX`] flag, the following protocol
1907/// runs between the Entry (initiator) and Exit (recipient) to provide on-chain payment
1908/// guarantees for the data relayed through the Session.
1909///
1910/// ### 1. PIX Parameter Negotiation (Session Initiation)
1911///
1912/// During [`SessionManager::new_session`], the Entry encodes its PIX SSA (Session Stealth
1913/// Address) parameters — a [`PixParams`] quadruple of `polys_per_ssa`, `shares_per_poly`,
1914/// `surplus_shares` and the curve `suite` — into the upper 32 bits of the 64-bit options word
1915/// carried in the most significant 64 bits of the `StartSession.additional_data` field, via
1916/// [`PixParams::into_additional_data`]. The first two describe how many polynomials and shares each
1917/// SSA will use; the third is how many extra shares per polynomial the Entry emits to absorb losses. Those three define
1918/// the data quota per SSA, which is `polys × (threshold + surplus) × SESSION_MTU` — the surplus is priced in rather
1919/// than free, since a cycle emits it whether or not any share is lost (see `pix_params_to_quota`).
1920///
1921/// The fourth is not a dimension and does not enter the quota: it names the elliptic curve the
1922/// Entry's build instantiates PIX over, which fixes the width of every curve-sized field later in
1923/// the handshake. It is fixed at build time on both sides and is therefore not negotiated — the Exit
1924/// refuses anything but its own, below.
1925///
1926/// What is announced is built from the installed [`SsaShareGenerator`]'s
1927/// [`SsaGeneratorConfig`](hopr_protocol_pix::SsaGeneratorConfig), never from the caller: the
1928/// generator is what produces the shares that go on the wire, so advertising anything else would
1929/// let the Session proceed while emitting shares the Exit cannot reconstruct. The dimensions are
1930/// therefore not configurable per Session — [`Capability::UsePIX`] is the whole per-Session switch,
1931/// and a node with no PIX toolbox installed refuses the Session. That check runs before the
1932/// initiation challenge slot is reserved, so a node that cannot serve PIX at all cannot have its
1933/// challenge slots exhausted by repeated requests for it.
1934///
1935/// The caller never needs to know the resulting quota in advance: it is computed node-side and
1936/// handed back per SSA in [`AgreedSsaQuota::quota_per_ssa`], which is what sizes the deposit.
1937///
1938/// On the Exit side, `check_pix_params` validates these parameters against:
1939/// - The protocol ranges, which [`PixParams::try_from_additional_data`] enforces as it unpacks.
1940/// - The configured [`IncomingSessionPixConfig::quota_range`] (by default ≈227 MiB–908 MiB). Dynamic admission checks
1941///   the smallest `batch_size × quota_per_ssa` up to the configured maximum; fixed admission checks the per-SSA quota
1942///   itself and retains the configured exact batch.
1943/// - Optionally, [`IncomingSessionPixConfig::enforce_pix`] rejects Sessions that do not offer PIX.
1944/// - The Exit only checks the *product* `polys × (threshold + surplus)`, not the individual values, so the Entry can
1945///   split it to suit its computing power. The computation is easily parallelizable in the number of polynomials, but
1946///   not in threshold. The surplus is inside that product, so redundancy is bought rather than taken: a cycle emits
1947///   `threshold + surplus` shares per polynomial come what may, and the deposit covers all of them.
1948///
1949/// If parameters are rejected, a [`StartErrorReason::UnacceptablePixParams`] error is returned.
1950///
1951/// ### 2. Exit SSA Request (`SsaRequest` → Entry)
1952///
1953/// Once the PIX parameters are accepted, the Exit spawns a `SessionPixSupervisor`, whose opening
1954/// `RequestSsa` action has `send_ssa_request` create a new SSA commitment via the server-side
1955/// [`SsaReconstructor`]. This produces an *Exit commitment* (a group element) that is sent back to
1956/// the Entry as a [`SsaServerCommitmentMessage`].
1957///
1958/// One action, and therefore one message, carries the batch selected during admission: no more than
1959/// [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request) SSAs at
1960/// contiguous indices, sharing the single `params` field, since every SSA in a Session uses the same
1961/// dimensions. The Entry caps what it will accept at
1962/// [`SessionManagerConfig::max_ssas_per_ssa_request`], and rejects an over-cap request in full while
1963/// replying with an `UnacceptablePixParams` [`StartErrorType`], so the Exit does not have to infer the
1964/// refusal from a deadline. The default is a batch of one, which is byte-for-byte the unbatched
1965/// exchange.
1966///
1967/// From here the supervisor owns the cycle's deadlines: `max_ssa_delivery_time` for the Entry's
1968/// commitment, then `max_deposit_wait` for the funds, then the recovery deadlines. Missing any of
1969/// them closes the Session with `ClosureReason::PixFailure`. The first two are multiplied by the batch
1970/// size, because a batch asks the Entry for that many commitment sets and that many deposits before
1971/// any of them can fairly be called late.
1972///
1973/// ### 3. Entry SSA Commitment (`SsaCommit` → Exit)
1974///
1975/// Upon receiving the [`SsaServerCommitmentMessage`], the Entry's `handle_ssa_request`
1976/// generates a *client commitment* using the shared [`SsaShareGenerator`] (which is also
1977/// used by the packet pipeline to embed PIX shares into return-path SURBs). The client
1978/// commitment is combined with the Exit commitment to derive the on-chain deposit address
1979/// via [`HoprPixSpec::group_to_deposit_address`].
1980///
1981/// The Entry then sends one or more [`SsaClientCommitmentMessage`]s back to the Exit and
1982/// emits a [`HoprSessionOutPixEvent::ReadyToDeposit`] to the upper layer, signaling that
1983/// funds can be deposited at the computed address.
1984///
1985/// "One or more" is hundreds at the deployed dimensions — one commitment per polynomial, chunked to
1986/// packet size — and the Exit can use *none* of it until every message has landed, since the SSA
1987/// commitment is the sum of them all. A single lost packet therefore used to cost the whole Session
1988/// on `max_ssa_delivery_time`, typically after the Entry had already been told to fund the address.
1989///
1990/// So a partially delivered commitment is repaired rather than waited out. Each arriving message
1991/// reports `SessionPixEvent::CommitmentProgress`, which arms (and re-arms) the supervisor's
1992/// [`commitment_recommit_interval`](crate::SupervisorConfig::commitment_recommit_interval); if
1993/// delivery then goes quiet with the set incomplete, `RequestCommitmentRetransmission` has
1994/// `send_commitment_retransmission_request` ask the Entry — via an `SsaRequest` whose
1995/// [`missing`](hopr_protocol_start::SsaServerCommitmentMessage::missing) scope names the polynomial
1996/// runs the reconstructor never received — to re-send exactly those. The Entry answers in
1997/// `handle_ssa_recommit` from the bytes it sent the first time, advancing nothing and announcing no
1998/// second deposit. A cycle for which *nothing* arrived never asks: it has no scope to name, and the
1999/// delivery deadline remains what ends it.
2000///
2001/// ### 4. Deposit Awaiting (Exit Side)
2002///
2003/// The Exit receives the client commitment messages in `handle_ssa_commit`, inserts the
2004/// coefficient commitments into the [`SsaReconstructor`], and extracts the deposit address.
2005/// It emits [`HoprSessionOutPixEvent::DepositNeeded`] to the upper layer with the
2006/// [`AgreedSsaQuota`] and a channel to confirm the deposit.
2007///
2008/// A verifiable commitment is what starts the deposit clock, so the supervisor is told
2009/// (`CommitmentVerified`) before anything can be reported against it. A `PixDepositObserver` task
2010/// then forwards every confirmation arriving on that channel as `DepositConfirmed`: top-ups
2011/// accumulate, and the supervisor is what decides when enough has landed. The observer carries no
2012/// timeout of its own — `max_deposit_wait` is the deadline, and a second authority racing the
2013/// first is exactly what the supervisor exists to prevent. If the upper layer drops the sender
2014/// without ever confirming, the observer reports that (`DepositObserverClosed`) rather than
2015/// letting the deadline run out on a deposit that is never coming.
2016///
2017/// Once the deposit suffices, the SSA moves to *recovering*. Its recovery deadlines start when it
2018/// reaches the front, and its deposit releases the egress gate only while it is there. When a paid
2019/// front leaves—whether recovered or retired after a tolerated failure—an unfunded successor
2020/// restores its own bounded predeposit allowance; an already funded successor stays open but starts
2021/// a fresh served-without-progress ceiling.
2022///
2023/// ### 5. SSA Collection, Recovery and Pipelining
2024///
2025/// As the Entry sends return-path SURBs during the Session, each SURB can carry a PIX
2026/// share generated from the client's polynomial set. The Exit's [`SsaReconstructor`]
2027/// collects these shares and reports how far the cycle has got via
2028/// [`HoprSessionInPixEvent::RecoveryProgress`]; `dispatch_pix_event` forwards those snapshots to
2029/// the supervisor, which uses them both to reset `max_recovery_idle` and to keep the gate serving.
2030///
2031/// When the last cycle of a batch reaches the *early recovery threshold* (≈85%), an
2032/// [`HoprSessionInPixEvent::SsaAlmostRecovered`] event fires and the supervisor answers with a
2033/// `RequestSsa` action for the next batch — pipelining the costly commitment exchange with the tail
2034/// of share collection. If that cycle is still awaiting its own commitment or deposit, the request
2035/// waits for funding; if two generations still own cycle state, it waits for one to be fully
2036/// released. Those checks are independent of the configurable share-order policy.
2037///
2038/// Once fully recovered, [`HoprSessionInPixEvent::SsaRecovered`] fires, allowing the Exit to
2039/// unlock and redeem the deposited funds. The reconstructor releases the live cycle immediately;
2040/// the supervisor retains a short tombstone, whose expiry emits `RetireSsa` to drop that cycle's
2041/// [`SsaCommitmentGuard`] and abort its deposit observer. The next batch is requested here if
2042/// `SsaAlmostRecovered` has not already done so. Observers are keyed by SSA index, so retiring one
2043/// cycle never cancels a pipelined successor's.
2044///
2045/// ### 6. Unverifiable Shares
2046///
2047/// Shares are not checked individually. Once a polynomial has collected `threshold` of them,
2048/// the reconstructor interpolates its constant term and compares it against the commitment; if
2049/// they disagree, at least one of those shares did not come from the committed polynomial and an
2050/// [`HoprSessionInPixEvent::UnverifiableShares`] event fires, carrying the reconstructor's running
2051/// total for the SSA rather than a delta.
2052///
2053/// The first such report closes the Session, unconditionally and with nothing to configure. The
2054/// failed polynomial is never retried, and the SSA is the sum of every polynomial's constant term,
2055/// so the cycle is already unrecoverable and can never pay; closing immediately caps what a
2056/// malicious Entry is served at `threshold` packets. The running total is logged rather than
2057/// accumulated. See `SessionPixSupervisor::on_unverifiable_shares` for why no tolerance is offered.
2058///
2059/// ### Configuring PIX at the Exit
2060///
2061/// The Exit configures PIX via [`IncomingSessionPixConfig`] within [`SessionManagerConfig`].
2062///
2063/// The [`PixToolbox`] (holding the [`SsaShareGenerator`] and [`SsaReconstructor`]) must
2064/// be provided via [`SessionManager::start`] for PIX to function.
2065pub struct SessionManager<S> {
2066    // Keeps track of Session initiations requests on the Client side.
2067    session_initiations: SessionInitiationCache,
2068    session_notifiers: Arc<OnceLock<SessionNotifiers>>,
2069    start_protocol_tx: StartProtocolMsgSink,
2070    /// Authoritative session count for admission control.
2071    /// Incremented atomically inside `allocate_session_slot` before the cache insertion,
2072    /// and decremented at every removal path (explicit close, eviction, guard rollback).
2073    active_sessions: Arc<std::sync::atomic::AtomicUsize>,
2074    sessions: moka::sync::Cache<SessionId, SessionSlot>,
2075    msg_sender: Arc<OnceLock<S>>,
2076    pix_toolbox: Arc<OnceLock<PixToolbox>>,
2077    cfg: SessionManagerConfig,
2078    /// Per-SessionId waiters notified when a new session slot is allocated. Lets message
2079    /// handlers that arrive before the slot insertion completes (e.g. SsaRequest vs
2080    /// SessionEstablished) await the slot once instead of busy-looping with sleeps.
2081    /// Keyed by SessionId so that only waiters for the relevant session are woken.
2082    slot_allocated: Arc<Mutex<HashMap<SessionId, Vec<oneshot::Sender<()>>>>>,
2083    /// Serialises `handle_ssa_request` per pseudonym.
2084    ///
2085    /// Start messages are processed under `for_each_concurrent`, so without this several
2086    /// `SsaRequest`s for one pseudonym run at once — and each reads the successor gate's admission
2087    /// state before any of them has advanced it. Every racer passes, and the Entry commits to (and
2088    /// funds) as many batches as the Exit cared to send, which is exactly what the gate exists to
2089    /// stop. The generator's own monotonic index is the backstop that keeps this from being a
2090    /// correctness hole, but it only rejects *equal or lower* indices: an Exit numbering its batches
2091    /// upwards races past it.
2092    ///
2093    /// An async lock rather than a blocking one because the guarded region awaits — commitment
2094    /// generation goes to a blocking pool, and publication awaits the transport.
2095    ///
2096    /// A `moka` cache rather than a map, for its idle eviction: a `HashMap` keyed by pseudonym would
2097    /// otherwise retain one entry per Session for the life of the process.
2098    ssa_request_locks: moka::future::Cache<HoprPseudonym, Arc<futures::lock::Mutex<()>>>,
2099    /// Live reconstructor-cycle bytes this node has committed to, summed over every PIX Session.
2100    ///
2101    /// The counterpart of `active_sessions`, and the reason that one is not sufficient: a Session is
2102    /// admitted on a slot count, but what it costs the reconstructor is set by the dimensions its
2103    /// peer offered. Charged in `handle_incoming_session_initiation` and returned by
2104    /// [`CycleBudgetReservation`]'s `Drop`, so no removal path has to remember to decrement it.
2105    live_cycle_bytes: Arc<std::sync::atomic::AtomicU64>,
2106    /// Where incoming Sessions are put to the session server for an admission decision.
2107    ///
2108    /// Unset on a node that runs no session server — an Entry, or a relay — where there is nobody to
2109    /// ask and every Session is admitted on this node's own terms.
2110    /// Behind a `Mutex` so every request goes through *one* sender.
2111    ///
2112    /// `futures::channel::mpsc` gives each cloned `Sender` its own guaranteed slot on top of the
2113    /// buffer, so sending through a fresh clone each time would let the queue grow with the number
2114    /// of concurrent initiations — the bound would not bound. `try_send` never blocks, so the lock
2115    /// is held only for the enqueue itself.
2116    admission_tx: Arc<OnceLock<parking_lot::Mutex<SessionAdmissionSink>>>,
2117}
2118
2119impl<S> Clone for SessionManager<S> {
2120    fn clone(&self) -> Self {
2121        Self {
2122            session_initiations: self.session_initiations.clone(),
2123            session_notifiers: self.session_notifiers.clone(),
2124            start_protocol_tx: self.start_protocol_tx.clone(),
2125            active_sessions: self.active_sessions.clone(),
2126            live_cycle_bytes: self.live_cycle_bytes.clone(),
2127            sessions: self.sessions.clone(),
2128            cfg: self.cfg.clone(),
2129            msg_sender: self.msg_sender.clone(),
2130            pix_toolbox: self.pix_toolbox.clone(),
2131            slot_allocated: Arc::clone(&self.slot_allocated),
2132            ssa_request_locks: self.ssa_request_locks.clone(),
2133            admission_tx: self.admission_tx.clone(),
2134        }
2135    }
2136}
2137
2138fn session_config(cfg: &SessionManagerConfig, capabilities: Capabilities) -> HoprSessionConfig {
2139    session_config_with(cfg, capabilities, None)
2140}
2141
2142/// As [`session_config`], with the initiating session's own head-of-line bound.
2143///
2144/// `None` inherits the node default; `Some(0)` disables the bound for this session; `Some(n)` sets
2145/// it. The zero-disables convention matches the node setting, so the per-session
2146/// and node-wide knobs cannot mean opposite things by the same value.
2147///
2148/// Sessions accepted from a peer pass `None`: the bound governs how *we* reassemble what arrives,
2149/// so it is ours to choose, not the initiator's to impose on us.
2150fn session_config_with(
2151    cfg: &SessionManagerConfig,
2152    capabilities: Capabilities,
2153    max_frames_behind_gap: Option<usize>,
2154) -> HoprSessionConfig {
2155    // Only a session that cannot recover the missing frame should abandon it early. With
2156    // retransmission the gap is a request away, so waiting is productive and cutting it short
2157    // would discard data that was on its way back; without it the wait is for something that is
2158    // never coming, and every frame behind the gap is held for nothing.
2159    let can_retransmit =
2160        capabilities.contains(Capability::RetransmissionAck) || capabilities.contains(Capability::RetransmissionNack);
2161
2162    // The session's own value when it stated one, the node's otherwise. `Some(0)` disables the
2163    // bound at either level -- a threshold of zero would abandon every gap before a single frame
2164    // arrived behind it, which nobody wants and which the sequencer would silently clamp to one.
2165    let bound = match max_frames_behind_gap.or(cfg.max_frames_behind_gap) {
2166        Some(0) | None => None,
2167        Some(n) => Some(n),
2168    };
2169
2170    HoprSessionConfig {
2171        capabilities,
2172        frame_mtu: cfg.frame_mtu,
2173        frame_timeout: cfg.max_frame_timeout,
2174        max_buffered_segments: cfg.max_buffered_segments,
2175        max_frames_behind_gap: (!can_retransmit).then_some(bound).flatten(),
2176    }
2177}
2178
2179#[cfg(feature = "telemetry")]
2180fn initialize_session_telemetry(
2181    session_id: SessionId,
2182    cfg: &SessionManagerConfig,
2183    capabilities: Capabilities,
2184    surb_estimator: Option<&AtomicSurbFlowEstimator>,
2185    surb_mgmt: Option<&Arc<BalancerStateValues>>,
2186) {
2187    initialize_session_metrics(session_id, session_config(cfg, capabilities));
2188    set_session_state(&session_id, SessionLifecycleState::Active);
2189    if let (Some(estimator), Some(mgmt)) = (surb_estimator, surb_mgmt) {
2190        set_session_balancer_data(&session_id, estimator.clone(), mgmt.clone());
2191    }
2192}
2193
2194/// Caps how many SURBs an outgoing Session data packet may carry, per the SURB balancer.
2195///
2196/// The default cap of `1` follows the balancer's target. Explicit limits of `0` or more than `1`
2197/// take precedence; `usize::MAX` leaves the packet's existing limit untouched.
2198///
2199/// Otherwise the cap comes from [`BalancerStateValues::organic_surbs_per_packet`], which yields `0`
2200/// once the counterparty is estimated to be at its target. That zero is the whole point of the
2201/// function: without it, the balancer can silence its keep-alives and still have organic SURBs
2202/// pouring into a full buffer, where each one evicts an older SURB and destroys the PIX share it
2203/// carried.
2204///
2205/// Nothing else has to change to keep the SURB accounting straight. This runs in front of the
2206/// counting sink, so `estimate_surbs_with_msg` reports the capped figure; and the PIX share for a
2207/// SURB is drawn per SURB while it is built, so a cap of `0` draws no share at all and leaves it
2208/// queued for a packet that can actually deliver it.
2209fn cap_organic_surbs(data: &mut ApplicationDataOut, max_surbs: usize, surb_mgmt: &BalancerStateValues) {
2210    if max_surbs != usize::MAX {
2211        data.packet_info.get_or_insert_default().max_surbs_in_packet = if max_surbs == 1 {
2212            surb_mgmt.organic_surbs_per_packet()
2213        } else {
2214            max_surbs
2215        };
2216    }
2217}
2218
2219/// Books an incoming packet's SURBs into the Exit's flow estimator.
2220///
2221/// `saved` SURBs entered the store and `evicted` older ones were dropped to make room for them. Both
2222/// are recorded so that `produced - consumed - evicted` tracks what the store actually holds: without
2223/// the eviction term the estimate stays inflated by every SURB the full store ever discarded, long
2224/// after they are gone -- which leaves the keep-alive level report and the PIX fill reserve both
2225/// reading a level that never falls. Split out from the receive closure so the accounting can be
2226/// unit-tested on its own.
2227fn account_incoming_surbs(estimator: &AtomicSurbFlowEstimator, saved: u64, evicted: u64) {
2228    estimator
2229        .produced
2230        .fetch_add(saved, std::sync::atomic::Ordering::Relaxed);
2231    estimator
2232        .evicted
2233        .fetch_add(evicted, std::sync::atomic::Ordering::Relaxed);
2234}
2235
2236async fn send_via_msg_sender<S, D>(
2237    msg_sender: &mut S,
2238    routing: DestinationRouting,
2239    data: D,
2240    error_context: &'static str,
2241) -> errors::Result<()>
2242where
2243    S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Unpin,
2244    S::Error: std::error::Error + Send + Sync + Clone + 'static,
2245    D: TryInto<ApplicationData>,
2246    D::Error: std::error::Error + Send + Sync + 'static,
2247{
2248    let app_data: ApplicationData = data.try_into().map_err(SessionManagerError::other)?;
2249    msg_sender
2250        .send((routing, ApplicationDataOut::with_no_packet_info(app_data)))
2251        .timeout(futures_time::time::Duration::from(EXTERNAL_SEND_TIMEOUT))
2252        .await
2253        .map_err(|_| {
2254            error!("timeout sending {error_context}");
2255            TransportSessionError::Timeout
2256        })?
2257        .map_err(|error| {
2258            error!(%error, "failed to send {error_context}");
2259            SessionManagerError::other(error)
2260        })?;
2261    Ok(())
2262}
2263
2264impl<S> SessionManager<S>
2265where
2266    S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Clone + Send + Sync + Unpin + 'static,
2267    S::Error: std::error::Error + Send + Sync + Clone + 'static,
2268{
2269    /// Creates a new instance given the [`config`](SessionManagerConfig).
2270    pub fn new(mut cfg: SessionManagerConfig) -> Self {
2271        let maximum_sessions = cfg.maximum_sessions;
2272        cfg.surb_balance_notify_period = cfg
2273            .surb_balance_notify_period
2274            .map(|p| p.max(MIN_SURB_BUFFER_NOTIFICATION_PERIOD));
2275        cfg.minimum_surb_buffer_duration = cfg.minimum_surb_buffer_duration.max(MIN_SURB_BUFFER_DURATION);
2276
2277        // Ensure the Frame MTU is at least the size of the Session segment MTU payload
2278        cfg.frame_mtu = cfg.frame_mtu.max(SESSION_MTU);
2279        cfg.max_frame_timeout = cfg.max_frame_timeout.max(MIN_FRAME_TIMEOUT);
2280
2281        // Both SSA batch knobs are range-validated in `HoprProtocolConfig`, but nothing in this crate
2282        // calls `validate()` — clamp here so a programmatically built config cannot ask for a batch of
2283        // zero (no request would ever be sent) or one large enough to blow past what
2284        // `MAX_SSA_BATCH_SIZE` exists to bound.
2285        cfg.max_ssas_per_ssa_request = cfg.max_ssas_per_ssa_request.clamp(1, MAX_SSA_BATCH_SIZE);
2286        cfg.pix_config.supervision.ssas_per_request =
2287            cfg.pix_config.supervision.ssas_per_request.clamp(1, MAX_SSA_BATCH_SIZE);
2288
2289        // Every supervisor duration, for the same reason and with more riding on it. A duration the
2290        // monotonic clock cannot represent makes `Instant::checked_add` return `None`, and every phase
2291        // reads an absent deadline as *no deadline* — so an over-large value does not produce a distant
2292        // deadline, it silently disables the rule. `validate_pix_supervision` rejects such a config, but
2293        // only a node built from a config file goes through it.
2294        //
2295        // Representability is necessary and not sufficient, though: `max_recovery_idle` also has to
2296        // stay under the reconstructor's `unused_verifier_lifetime`, or the supervisor waits on a
2297        // cycle whose state was reclaimed hours earlier. Clamping only to the monotonic-clock cap left
2298        // exactly that — `Duration::MAX` became 24 h against a 30-minute default lifetime — so the
2299        // normalized config was representable and still invalid.
2300        //
2301        // The reconstructor is not installed until `start`, so the *default* configuration is what
2302        // this can normalize against. `start` re-checks against the one actually installed, which is
2303        // where a caller pairing a non-default reconstructor is caught.
2304        //
2305        // The two batch-scaled deadlines are clamped by the scaled value, since that is what is armed;
2306        // dividing by the batch size (already clamped to at least 1 above) is what keeps the product
2307        // under the cap.
2308        {
2309            use hopr_protocol_pix::SsaReconstructorConfig;
2310
2311            let sup = &mut cfg.pix_config.supervision;
2312            let cap = crate::supervision::MAX_SUPERVISOR_DURATION;
2313            let per_cycle_cap = cap / sup.ssas_per_request as u32;
2314            sup.max_ssa_delivery_time = sup.max_ssa_delivery_time.min(per_cycle_cap);
2315            sup.max_deposit_wait = sup.max_deposit_wait.min(per_cycle_cap);
2316            sup.max_recovery_time = sup.max_recovery_time.min(cap);
2317            sup.tombstone_retention_window = sup.tombstone_retention_window.min(cap);
2318
2319            // Strictly under the lifetime, which is what `validate_pix_supervision` requires; a
2320            // saturating subtraction keeps a pathologically short lifetime from wrapping. The lower
2321            // bound is not clamped: raising a too-small value would be inventing a policy rather than
2322            // bounding one, and it is what `validate_pix_supervision` reports.
2323            let idle_cap = SsaReconstructorConfig::default()
2324                .unused_verifier_lifetime
2325                .saturating_sub(Duration::from_secs(1))
2326                .min(cap);
2327            sup.max_recovery_idle = sup.max_recovery_idle.min(idle_cap);
2328
2329            // The fill heartbeat is armed as a deadline like the rest, so it takes the same cap. The
2330            // rate ceiling is clamped for a different reason: `RateController::MIN_DELAY` makes
2331            // anything above `MAX_FILL_RATE` unrepresentable, and a controller that silently ignores
2332            // the one bound on this node's self-generated egress is worse than one that is loud about
2333            // refusing it — which is what `validate_pix_supervision` does for a config that was
2334            // validated. This branch is for the programmatic ones that never were.
2335            //
2336            // The heartbeat needs the *lower* bound for the same reason the ceiling needs its upper
2337            // one, and it is the one that bites: a zero period makes `FillRate::as_packets_per_sec`
2338            // report zero, so the planner's own floor is zero, every branch of `FillPlanner::bound`
2339            // that returns the heartbeat returns a zero period, and `PixFillControl::apply` hands
2340            // `set_rate_per_unit(1, 1µs)` to the controller — which `MIN_DELAY` turns into 10 000
2341            // packets/s. The one clamp that exists to bound self-generated egress is bypassed by the
2342            // one field that was left unbounded below. The floor is the exact dual of the ceiling —
2343            // the period of `MAX_FILL_RATE` — rather than a new number, so the two cannot drift; an
2344            // inverted heartbeat/ceiling pair is already resolved ceiling-wins inside `bound`, which
2345            // is what makes any positive floor safe here.
2346            sup.fill.heartbeat = sup
2347                .fill
2348                .heartbeat
2349                .clamp(Duration::from_secs(1) / crate::supervision::MAX_FILL_RATE, cap);
2350            sup.fill.max_rate = sup.fill.max_rate.clamp(1, crate::supervision::MAX_FILL_RATE);
2351        }
2352
2353        // The admission wait is spent inside the peer's initiation timeout, so a value at or above
2354        // it buys nothing: the peer has already given up by the time the wait expires, and the
2355        // Session was refused anyway. Clamped to one leg of that budget, leaving the other for the
2356        // Start exchange the decision is part of. Clamped rather than rejected because every other
2357        // cross-field rule in this constructor normalizes, and because the ceiling is derived from a
2358        // field the caller may also have left at its default.
2359        cfg.session_admission_timeout = cfg.session_admission_timeout.min(initiation_timeout_max_one_way(
2360            cfg.initiation_timeout_base,
2361            RoutingOptions::MAX_INTERMEDIATE_HOPS,
2362        ));
2363
2364        #[cfg(all(feature = "telemetry", not(test)))]
2365        METRIC_ACTIVE_SESSIONS.set(0.0);
2366
2367        let active_sessions: Arc<std::sync::atomic::AtomicUsize> = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2368        let active_sessions_for_listener = active_sessions.clone();
2369
2370        let msg_sender = Arc::new(OnceLock::new());
2371        let initiation_timeout =
2372            2 * initiation_timeout_max_one_way(cfg.initiation_timeout_base, RoutingOptions::MAX_INTERMEDIATE_HOPS);
2373        let pix_toolbox: Arc<OnceLock<PixToolbox>> = Arc::new(OnceLock::new());
2374        Self {
2375            msg_sender: msg_sender.clone(),
2376            session_initiations: moka::sync::Cache::builder()
2377                .max_capacity(maximum_sessions as u64)
2378                .time_to_live(initiation_timeout)
2379                .build(),
2380            sessions: moka::sync::Cache::builder()
2381                .max_capacity(maximum_sessions as u64)
2382                .time_to_idle(cfg.idle_timeout)
2383                .eviction_listener(
2384                    move |session_id: Arc<SessionId>, entry: SessionSlot, reason| match &reason {
2385                        moka::notification::RemovalCause::Expired | moka::notification::RemovalCause::Size => {
2386                            trace!(?session_id, ?reason, "session evicted from the cache");
2387                            // Reconstructor state is released by `close_session` aborting the PIX
2388                            // action driver: the driver owns an `SsaCommitmentGuard` per live cycle,
2389                            // and dropping its future drops them. That bounds the release to the
2390                            // cycles actually in flight, where enumerating every index this Session
2391                            // had ever used was unbounded in its lifetime.
2392                            active_sessions_for_listener.fetch_sub(1, Ordering::Relaxed);
2393                            close_session(*session_id.as_ref(), entry, ClosureReason::Eviction);
2394                        }
2395                        _ => {}
2396                    },
2397                )
2398                .build(),
2399            pix_toolbox,
2400            session_notifiers: Arc::new(OnceLock::new()),
2401            start_protocol_tx: Arc::new(OnceLock::new()),
2402            active_sessions,
2403            live_cycle_bytes: Arc::new(std::sync::atomic::AtomicU64::new(0)),
2404            cfg,
2405            slot_allocated: Arc::new(Mutex::new(HashMap::new())),
2406            // Idle rather than live TTL, and generous: the entry only has to outlive the requests that
2407            // race for it, and one evicted between two cycles is simply recreated by the next request.
2408            // Matches the generator's own per-pseudonym cache, which is what the guarded region reads.
2409            ssa_request_locks: moka::future::Cache::builder()
2410                .time_to_idle(Duration::from_secs(1800))
2411                .build(),
2412            admission_tx: Arc::new(OnceLock::new()),
2413        }
2414    }
2415
2416    /// Starts the instance with the given `msg_sender` `Sink`
2417    /// and a channel `new_session_notifier` used to notify when a new incoming session is opened to us.
2418    ///
2419    /// Optionally, the PIX processor and event sink can be provided for handling PIX protocol.
2420    /// If not specified, the `SessionManager` will not handle PIX protocol.
2421    ///
2422    /// `admission` is where incoming Sessions are put to the session server for a per-Session
2423    /// decision before they are established. Without it — an Entry, or a relay, or any caller that
2424    /// installed no session server — every Session is admitted on this node's own configured terms,
2425    /// which is the behaviour of a node that does not distinguish between targets.
2426    ///
2427    /// This method must be called prior to any calls to [`SessionManager::new_session`] or
2428    /// [`SessionManager::dispatch_message`].
2429    pub fn start<T>(
2430        &self,
2431        msg_sender: S,
2432        new_session_notifier: T,
2433        pix: Option<PixToolbox>,
2434        admission: Option<SessionAdmissionSink>,
2435    ) -> errors::Result<Vec<AbortHandle>>
2436    where
2437        T: futures::Sink<IncomingSession> + Send + 'static,
2438        T::Error: std::error::Error + Send + Sync + 'static,
2439    {
2440        // Every fallible check runs before the first `OnceLock::set` below.
2441        //
2442        // `start` publishes the manager's started state through those locks, and a `OnceLock` cannot
2443        // be un-set. Validating after `msg_sender` had been filled left a manager that reports
2444        // `is_started() == false` — no workers, no notifier — and yet refuses every retry with
2445        // `AlreadyStarted`: neither running nor recoverable, out of what is an ordinary configuration
2446        // error the caller could have corrected and retried on the same instance.
2447        if let Some(pix) = pix.as_ref() {
2448            validate_incoming_session_pix_config(&self.cfg.pix_config, MAX_ASSUMED_SESSION_PACKET_RATE)?;
2449
2450            // The authoritative cross-component check, and the first moment it can be made: the
2451            // supervisor's deadlines are meaningless without the reconstructor lifetimes they race
2452            // against, and the reconstructor arrives here rather than at construction.
2453            //
2454            // `SessionManager::new` normalizes against the *default* reconstructor config, which is
2455            // what makes the common programmatic case correct without an API break. A caller that
2456            // pairs a supervisor config with a non-default reconstructor is caught here instead — as
2457            // an error rather than a clamp, because at this point both halves were chosen
2458            // deliberately and silently overriding one of them would be the wrong answer.
2459            crate::supervision::validate_pix_supervision(
2460                &self.cfg.pix_config.supervision,
2461                pix.share_processor.config(),
2462            )?;
2463        }
2464
2465        self.msg_sender
2466            .set(msg_sender)
2467            .map_err(|_| SessionManagerError::AlreadyStarted)?;
2468
2469        if let Some(pix) = pix {
2470            self.pix_toolbox
2471                .set(pix)
2472                .map_err(|_| SessionManagerError::AlreadyStarted)?;
2473        }
2474
2475        if let Some(admission) = admission {
2476            self.admission_tx
2477                .set(parking_lot::Mutex::new(admission))
2478                .map_err(|_| SessionManagerError::AlreadyStarted)?;
2479        }
2480
2481        // Re-map the user-provided sink errors to `SessionManagerError` and erase the concrete
2482        //  type so that the `SessionManager` does not need to be generic over it. This also avoids
2483        // having to spawn a separate task to forward items between channels: senders simply lock
2484        // the sink and send directly.
2485        let new_session_notifier: BoxSink<IncomingSession> =
2486            Box::pin(new_session_notifier.sink_map_err(SessionManagerError::other));
2487        let new_session_notifier = Arc::new(hopr_utils::runtime::prelude::Mutex::new(new_session_notifier));
2488
2489        let (session_close_tx, session_close_rx) =
2490            crossfire::mpsc::bounded_blocking_async(self.cfg.maximum_sessions + 10);
2491        self.session_notifiers
2492            .set((new_session_notifier, session_close_tx))
2493            .map_err(|_| SessionManagerError::AlreadyStarted)?;
2494
2495        let (start_protocol_tx, start_protocol_rx) =
2496            crossfire::mpsc::bounded_blocking_async(start_protocol_channel_capacity(&self.cfg));
2497        let _ = self.start_protocol_tx.set(start_protocol_tx);
2498
2499        let myself = self.clone();
2500        let closure_diag = hopr_utils::runtime::diagnostics::ConcurrentDiagnostics::new(
2501            "session_close_for_each_concurrent",
2502            module_path!(),
2503            file!(),
2504            line!(),
2505        );
2506        let ah_closure_notifications = hopr_utils::spawn_as_abortable_named!(
2507            "session_close_notifications",
2508            session_close_rx.into_stream().for_each_concurrent(
2509                self.cfg.maximum_sessions + 10,
2510                move |(session_id, closure_reason)| {
2511                    let myself = myself.clone();
2512                    let closure_diag = closure_diag.clone();
2513                    closure_diag.wrap(|| {
2514                        // These notifications come from the Sessions themselves once
2515                        // an empty read is encountered, which means the closure was done by the
2516                        // other party.
2517                        //
2518                        // Routed through `close_session_with_reason` rather than removing the slot
2519                        // here, so that this path — the one an Exit-side session server actually
2520                        // closes on — can answer with a SURB drain like every other. Identical
2521                        // behaviour when it does not.
2522                        if !myself.close_session_with_reason(&session_id, closure_reason) {
2523                            // Do not treat this as an error
2524                            debug!(
2525                                ?session_id,
2526                                ?closure_reason,
2527                                "could not find session id to close, maybe the session is already closed"
2528                            );
2529                        }
2530                        futures::future::ready(())
2531                    })
2532                }
2533            )
2534        );
2535
2536        // This is necessary to evict expired entries from the caches if
2537        // no session-related operations happen at all.
2538        // This ensures the dangling expired sessions are properly closed
2539        // and their closure is timely notified to the other party.
2540        let myself = self.clone();
2541        let ah_session_expiration = hopr_utils::spawn_as_abortable!(async move {
2542            let jitter = hopr_api::types::crypto_random::random_float_in_range(1.0..1.5);
2543            let timeout = 2 * initiation_timeout_max_one_way(
2544                myself.cfg.initiation_timeout_base,
2545                RoutingOptions::MAX_INTERMEDIATE_HOPS,
2546            )
2547            .min(myself.cfg.idle_timeout)
2548            .mul_f64(jitter)
2549                / 2;
2550            futures_time::stream::interval(timeout.into())
2551                .for_each(|_| async {
2552                    trace!("executing session cache evictions");
2553                    myself.sessions.run_pending_tasks();
2554                    myself.session_initiations.run_pending_tasks();
2555                })
2556                .await;
2557        });
2558
2559        // Begin processing of Start protocol messages
2560        let myself = self.clone();
2561        let ah_start_protocol = hopr_utils::spawn_as_abortable_named!(
2562            "session_start_protocol_processor",
2563            start_protocol_rx.into_stream().for_each_concurrent(
2564                Some(self.cfg.maximum_sessions + 10),
2565                move |(pseudonym, protocol_msg)| {
2566                    let myself = myself.clone();
2567                    async move {
2568                        let result = match protocol_msg {
2569                            HoprStartProtocol::StartSession(session_req) => {
2570                                myself.handle_incoming_session_initiation(pseudonym, session_req).await
2571                            }
2572                            HoprStartProtocol::SessionEstablished(est) => myself.handle_session_established(est).await,
2573                            HoprStartProtocol::SessionError(error_type) => {
2574                                myself.handle_session_error(error_type).await
2575                            }
2576                            HoprStartProtocol::KeepAlive(msg) => myself.handle_keep_alive(msg).await,
2577                            HoprStartProtocol::SsaCommit(client_commit_msg) => {
2578                                myself.handle_ssa_commit(pseudonym, client_commit_msg).await
2579                            }
2580                            HoprStartProtocol::SsaRequest(server_commit_msg) => {
2581                                myself.handle_ssa_request(pseudonym, server_commit_msg).await
2582                            }
2583                        };
2584
2585                        if let Err(error) = result {
2586                            error!(%error, "failed to process Start protocol message");
2587                        }
2588                    }
2589                }
2590            )
2591        );
2592
2593        Ok(vec![ah_closure_notifications, ah_session_expiration, ah_start_protocol])
2594    }
2595
2596    /// Check if [`start`](SessionManager::start) has been called and the instance is running.
2597    pub fn is_started(&self) -> bool {
2598        self.session_notifiers.get().is_some()
2599    }
2600
2601    /// Atomically allocates a new [`SessionSlot`] for `session_id` and returns an RAII
2602    /// [`SessionSlotGuard`] for it.
2603    ///
2604    /// Establishing a session involves several fallible steps *after* the slot has been
2605    /// inserted. The returned guard rolls the slot back - tearing the partially
2606    /// established session down via [`close_session`] - unless it is
2607    /// [committed](SessionSlotGuard::commit).
2608    ///
2609    /// The active-sessions gauge is incremented here, atomically with the insertion and
2610    /// the guard creation, precisely so that it is always paired with the guard's
2611    /// rollback decrement (performed through [`close_session`]). This keeps the gauge
2612    /// accurate: it is never decremented for a slot that was not counted in the first
2613    /// place, and every counted slot is decremented exactly once when it leaves the cache.
2614    ///
2615    /// Returns `None` if a slot for `session_id` already exists; in that case nothing is
2616    /// inserted, the gauge is left untouched, and no guard is produced. The atomic `entry`
2617    /// API guarantees that only one concurrent caller can claim the slot for a given
2618    /// pseudonym (avoiding a TOCTOU race), which also rules out loopback sessions onto
2619    /// ourselves.
2620    ///
2621    /// Capacity is enforced by an atomic counter incremented *before* the cache insertion,
2622    /// making it impossible for two concurrent callers (with different session IDs) to both
2623    /// succeed when the cache is already at `maximum_sessions`.
2624    fn allocate_session_slot(&self, session_id: SessionId, slot: SessionSlot) -> Option<SessionSlotGuard<'_>> {
2625        // Try to claim a session slot before touching the cache. `fetch_update` atomically
2626        // increments only if the value is strictly below the limit, preventing two concurrent
2627        // callers from both succeeding when already at capacity.
2628        let counter = &self.active_sessions;
2629        #[allow(clippy::incompatible_msrv)]
2630        let did_reserve = counter
2631            .try_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
2632                (n < self.cfg.maximum_sessions).then_some(n + 1)
2633            })
2634            .is_ok();
2635
2636        if !did_reserve {
2637            return None;
2638        }
2639
2640        let result =
2641            self.sessions
2642                .entry(session_id)
2643                .and_compute_with(|entry: Option<moka::Entry<SessionId, SessionSlot>>| {
2644                    if entry.is_none() {
2645                        moka::ops::compute::Op::Put(slot)
2646                    } else {
2647                        // Duplicate key — release the reservation so the counter stays accurate.
2648                        counter.fetch_sub(1, Ordering::Relaxed);
2649                        moka::ops::compute::Op::Nop
2650                    }
2651                });
2652
2653        match result {
2654            moka::ops::compute::CompResult::Inserted(_) => {
2655                // Notify any waiting message handler (e.g. handle_ssa_request) that the slot
2656                // is now available. Drain and signal the senders registered for this SessionId.
2657                if let Some(waiters) = self
2658                    .slot_allocated
2659                    .lock()
2660                    .unwrap_or_else(|e| e.into_inner())
2661                    .remove(&session_id)
2662                {
2663                    for w in waiters {
2664                        let _ = w.send(());
2665                    }
2666                }
2667                // take_guard borrows self, so the guard stores the counter clone separately.
2668                Some(SessionSlotGuard::new(&self.sessions, session_id, counter.clone()))
2669            }
2670            _ => None,
2671        }
2672    }
2673
2674    /// Initiates a new outgoing Session to `destination` with the given configuration.
2675    ///
2676    /// If the Session's counterparty does not respond within
2677    /// the [configured](SessionManagerConfig) period,
2678    /// this method returns [`TransportSessionError::Timeout`].
2679    ///
2680    /// It will also fail if the instance has not been [started](SessionManager::start).
2681    pub async fn new_session(
2682        &self,
2683        destination: Address,
2684        target: SessionTarget,
2685        cfg: SessionClientConfig,
2686    ) -> errors::Result<HoprSession> {
2687        self.sessions.run_pending_tasks();
2688        if self.cfg.maximum_sessions <= self.active_sessions.load(Ordering::Relaxed) {
2689            return Err(SessionManagerError::TooManySessions.into());
2690        }
2691
2692        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
2693
2694        let (tx_initiation_done, rx_initiation_done): (
2695            crossfire::MTx<crossfire::mpsc::One<_>>,
2696            crossfire::AsyncRx<crossfire::mpsc::One<_>>,
2697        ) = crossfire::mpsc::build(crossfire::mpsc::One::new());
2698
2699        let current_ssa_state = Arc::new(OnceLock::new());
2700
2701        let mut additional_data = 0_u64;
2702
2703        // SURB balancer target announcement is encoded in the lower 32-bits of the options word
2704        if !cfg.capabilities.contains(Capability::NoRateControl) {
2705            additional_data |= cfg
2706                .surb_management
2707                .map(|c| c.target_surb_buffer_size)
2708                .unwrap_or(
2709                    self.cfg.initial_return_session_egress_rate as u64
2710                        * self
2711                            .cfg
2712                            .minimum_surb_buffer_duration
2713                            .max(MIN_SURB_BUFFER_DURATION)
2714                            .as_secs(),
2715                )
2716                .min(u32::MAX as u64);
2717        }
2718
2719        // PIX quota parameter announcement is encoded in the upper 32-bits of the options word.
2720        // Run these validations BEFORE reserving the initiation challenge slot so that a
2721        // repeated invalid request cannot exhaust all challenge slots.
2722        if cfg.capabilities.contains(Capability::UsePIX) {
2723            // PIX requires at least 1 intermediate hop on the return path so that PIX
2724            // shares can be encrypted with the first relayer's ticket-challenge solution
2725            // (`HalfKey`) and delivered via return-path SURBs. With 0 intermediate hops
2726            // (a direct Exit→Entry SURB), there is no relayer to provide the challenge
2727            // solution, so shares are never embedded — the ongoing PIX share delivery
2728            // mechanism is dead and the Exit's quota is never replenished.
2729            if cfg.return_path_options.count_hops() == 0 {
2730                return Err(SessionManagerError::Other(anyhow!(
2731                    "UsePIX requires at least 1 intermediate hop on the return path, got 0"
2732                ))
2733                .into());
2734            }
2735
2736            // Validate that PIX toolbox is available before advertising UsePIX
2737            let pix_toolbox = self
2738                .pix_toolbox
2739                .get()
2740                .ok_or_else(|| SessionManagerError::Other(anyhow!("UsePIX requested but no PIX toolbox installed")))?;
2741
2742            // The installed generator is what actually produces the shares that go on the wire, so
2743            // it — not the caller — is the source of the announced parameters. Letting a caller name
2744            // them instead would allow a Session to proceed while producing shares the Exit cannot
2745            // reconstruct, so the dimensions are not a per-Session choice at all: `UsePIX` is the
2746            // whole switch, and everything downstream (the per-SSA quota, and so the deposit amount
2747            // reported to the funding strategy in `AgreedSsaQuota`) is derived from what follows.
2748            // The same source for the dimensions and for the curve suite: `HoprPixSpec` is what the
2749            // installed generator is instantiated over, so the announced suite cannot disagree with
2750            // the one that will actually produce the shares.
2751            let gen_cfg = pix_toolbox.share_generator.config();
2752            let params = PixParams::try_from_config::<HoprPixSpec>(gen_cfg)
2753                .map_err(|error| SessionManagerError::Other(anyhow!("invalid PIX dimensions: {error}")))?;
2754
2755            let _ = current_ssa_state.set(SessionSsaState::new(params));
2756            additional_data = params.into_additional_data(additional_data as u32);
2757        }
2758
2759        let (challenge, _) = insert_into_next_slot(
2760            &self.session_initiations,
2761            |ch| {
2762                if let Some(challenge) = ch {
2763                    ((challenge + 1) % hopr_api::types::crypto_random::MAX_RANDOM_INTEGER).max(MIN_CHALLENGE)
2764                } else {
2765                    hopr_api::types::crypto_random::random_integer(MIN_CHALLENGE, None)
2766                }
2767            },
2768            |_| tx_initiation_done,
2769            Some(self.cfg.maximum_sessions as u64),
2770        )
2771        .ok_or(SessionManagerError::NoChallengeSlots)?; // almost impossible with u64
2772
2773        // Prepare the session initiation message in the Start protocol
2774        trace!(challenge, ?cfg, "initiating session with config");
2775        let start_session_msg = HoprStartProtocol::StartSession(StartInitiation {
2776            challenge,
2777            target,
2778            capabilities: HoprSessionCapabilities(cfg.capabilities),
2779            additional_data: into_initiation_additional_data(additional_data),
2780        });
2781
2782        let pseudonym = cfg.pseudonym.unwrap_or(HoprPseudonym::random());
2783        let forward_routing = DestinationRouting::Forward {
2784            destination: Box::new(destination.into()),
2785            pseudonym: Some(pseudonym), // Session must use a fixed pseudonym already
2786            forward_options: cfg.forward_path_options.clone(),
2787            return_options: cfg.return_path_options.clone().into(),
2788        };
2789
2790        // Send the Session initiation message
2791        info!(challenge, %pseudonym, %destination, "new session request");
2792        send_via_msg_sender(
2793            &mut msg_sender,
2794            forward_routing.clone(),
2795            start_session_msg,
2796            "session request message",
2797        )
2798        .await
2799        .map_err(|error| {
2800            self.session_initiations.remove(&challenge);
2801            TransportSessionError::packet_sending(error)
2802        })?;
2803
2804        // The timeout is given by the number of hops requested
2805        let initiation_timeout: futures_time::time::Duration = initiation_timeout_max_one_way(
2806            self.cfg.initiation_timeout_base,
2807            cfg.forward_path_options.count_hops() + cfg.return_path_options.count_hops() + 2,
2808        )
2809        .into();
2810
2811        // Await session establishment response from the Exit node or timeout
2812
2813        trace!(challenge, "awaiting session establishment");
2814        match rx_initiation_done
2815            .into_stream()
2816            .try_next()
2817            .timeout(initiation_timeout)
2818            .await
2819        {
2820            Ok(Ok(Some(est))) => {
2821                // Session has been established, construct it
2822                let session_id = est.session_id;
2823                debug!(challenge = est.orig_challenge, ?session_id, "started a new session");
2824
2825                let (session_tx, session_rx) =
2826                    crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
2827                let (session_rx, session_rx_ah) = hopr_utils::runtime::DropAbortable::new(session_rx.into_stream());
2828
2829                let mut abort_handles = AbortableList::default();
2830                abort_handles.insert(SessionHandles::Ingress, session_rx_ah);
2831
2832                let notifier = self
2833                    .session_notifiers
2834                    .get()
2835                    .map(|(_, notifier)| {
2836                        let notifier = notifier.clone();
2837                        Box::new(move |session_id: SessionId, reason: ClosureReason| {
2838                            let _ = notifier
2839                                .try_send((session_id, reason))
2840                                .inspect_err(|error| error!(%session_id, %error, "failed to notify session closure"));
2841                        })
2842                    })
2843                    .ok_or(SessionManagerError::NotStarted)?;
2844
2845                // NOTE: the Exit node can have different `max_surb_buffer_size`
2846                // setting on the Session manager, so it does not make sense to cap it here
2847                // with our maximum value.
2848                if let Some(balancer_config) = cfg.surb_management {
2849                    let surb_estimator = AtomicSurbFlowEstimator::default();
2850                    let surbs_per_keep_alive = KeepAliveMessage::from(session_id).max_surbs()? as u32;
2851
2852                    // Sender responsible for keep-alive and Session data will be counting produced SURBs
2853                    let surb_estimator_clone = surb_estimator.clone();
2854                    let full_surb_scoring_sender =
2855                        msg_sender.with(move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
2856                            let produced = data.estimate_surbs_with_msg() as u64;
2857                            // Count how many SURBs we sent with each packet
2858                            surb_estimator_clone
2859                                .produced
2860                                .fetch_add(produced, std::sync::atomic::Ordering::Relaxed);
2861                            #[cfg(feature = "telemetry")]
2862                            telemetry::record_session_surb_produced(&session_id, produced);
2863                            futures::future::ok::<_, S::Error>((routing, data))
2864                        });
2865
2866                    let surb_mgmt = Arc::new(BalancerStateValues::from(balancer_config));
2867                    // The counterparty's store is the same bounded ring buffer as ours, so its
2868                    // capacity bounds what our `produced - consumed` estimate can legitimately
2869                    // claim it is holding.
2870                    surb_mgmt.set_counterparty_buffer_capacity(self.cfg.maximum_surb_buffer_size as u64);
2871
2872                    // For standard Session data we first reduce the number of SURBs we want to produce,
2873                    // unless requested to always max them out
2874                    let max_surbs_in_packet = cfg.max_surbs_per_data_packet;
2875                    let surb_mgmt_for_tx = surb_mgmt.clone();
2876                    let reduced_surb_scoring_sender = full_surb_scoring_sender.clone().with(
2877                        // NOTE: this is put in-front of the `full_surb_scoring_sender`,
2878                        // so that its estimate of SURBs gets automatically updated based on
2879                        // the `max_surbs_in_packets` set here.
2880                        move |(routing, mut data): (DestinationRouting, ApplicationDataOut)| {
2881                            cap_organic_surbs(&mut data, max_surbs_in_packet, &surb_mgmt_for_tx);
2882                            futures::future::ok::<_, S::Error>((routing, data))
2883                        },
2884                    );
2885
2886                    // Spawn the SURB-bearing keep alive stream towards the Exit. Suspended until the
2887                    // balancer below gives the controller a rate.
2888                    let ka_controller = RateController::new(0, Duration::from_secs(1));
2889                    let ka_abort_handle = utils::spawn_keep_alive_stream(
2890                        session_id,
2891                        full_surb_scoring_sender,
2892                        forward_routing.clone(),
2893                        if self.cfg.surb_target_notify {
2894                            SurbNotificationMode::Target
2895                        } else {
2896                            SurbNotificationMode::DoNotNotify
2897                        },
2898                        surb_mgmt.clone(),
2899                        &ka_controller,
2900                        // The Entry's balancer owns this stream outright and there is no second
2901                        // producer to arbitrate between, so every packet the rate limiter releases is
2902                        // one the balancer asked for.
2903                        || true,
2904                    );
2905                    abort_handles.insert(SessionHandles::KeepAlive, ka_abort_handle);
2906
2907                    // Spawn the SURB balancer, which will decide on the initial SURB rate.
2908                    debug!(%session_id, ?balancer_config ,"spawning entry SURB balancer");
2909                    let balancer = SurbBalancer::new(
2910                        session_id,
2911                        // The setpoint and output limit is immediately reconfigured by the SurbBalancer
2912                        PidBalancerController::from_gains(PidControllerGains::from_env_or_default()),
2913                        surb_estimator.clone(),
2914                        // Convert the requested SURB rate to packets using the keep-alive's capacity.
2915                        SurbControllerWithCorrection(ka_controller, surbs_per_keep_alive),
2916                        surb_mgmt.clone(),
2917                    );
2918
2919                    let (level_stream, balancer_abort_handle) =
2920                        balancer.start_control_loop(self.cfg.balancer_sampling_interval);
2921                    abort_handles.insert(SessionHandles::Balancer, balancer_abort_handle);
2922
2923                    let returned_packets = Arc::new(std::sync::atomic::AtomicU64::new(0));
2924
2925                    // Insert the slot before the SURB readiness wait so any echo packets that
2926                    // arrive during pre-loading are accepted rather than dropped as unknown.
2927                    // Early return from the wait below drops slot_guard uncommitted, which removes
2928                    // the slot and calls close_session → abort_all() on the spawned tasks.
2929                    let mut slot_guard = self
2930                        .allocate_session_slot(
2931                            session_id,
2932                            SessionSlot {
2933                                session_tx,
2934                                routing_opts: forward_routing.clone(),
2935                                abort_handles: Arc::new(parking_lot::Mutex::new(abort_handles)),
2936                                surb_mgmt: surb_mgmt.clone(),
2937                                surb_estimator: surb_estimator.clone(),
2938                                current_ssa_state,
2939                                // Entry side: the Exit is authoritative for the PIX lifecycle, so
2940                                // there is no supervisor here and nothing gates egress.
2941                                pix_supervisor: Default::default(),
2942                                pix_egress_gate: Default::default(),
2943                                pix_fill: Default::default(),
2944                                returned_packets: returned_packets.clone(),
2945                                // Nor does it hold reconstructor state: the live-cycle budget is
2946                                // charged by the side that reconstructs.
2947                                cycle_budget: None,
2948                            },
2949                        )
2950                        .ok_or_else(|| {
2951                            // Session already exists; it means it is most likely a loopback attempt
2952                            error!(%session_id, "session already exists - loopback attempt");
2953                            SessionManagerError::Loopback
2954                        })?;
2955
2956                    // Prevent the slot from being evicted by time_to_idle while SURBs are
2957                    // pre-loading. Each `get()` call resets the idle timer; the task is
2958                    // aborted as soon as the readiness wait resolves.
2959                    let sessions_keepalive = self.sessions.clone();
2960                    let touch_period = (self.cfg.idle_timeout / 2).max(self.cfg.min_session_touch_period);
2961                    let slot_keepalive = hopr_utils::runtime::prelude::spawn(async move {
2962                        loop {
2963                            hopr_utils::runtime::prelude::sleep(touch_period).await;
2964                            let _ = sessions_keepalive.get(&session_id);
2965                        }
2966                    });
2967
2968                    // TODO: consider making this interactive = other party reports the exact level periodically
2969                    let wait_result = level_stream
2970                        .skip_while(|current_level| {
2971                            futures::future::ready(*current_level < balancer_config.target_surb_buffer_size / 2)
2972                        })
2973                        .next()
2974                        .timeout(futures_time::time::Duration::from(SESSION_READINESS_TIMEOUT))
2975                        .await;
2976                    slot_keepalive.abort();
2977                    match wait_result {
2978                        Ok(Some(surb_level)) => {
2979                            info!(%session_id, surb_level, "session is ready");
2980                        }
2981                        Ok(None) => {
2982                            return Err(
2983                                SessionManagerError::other(anyhow!("surb balancer was cancelled prematurely")).into(),
2984                            );
2985                        }
2986                        Err(_) => {
2987                            warn!(%session_id, "session didn't reach target SURB buffer size in time");
2988                        }
2989                    }
2990
2991                    #[cfg(all(feature = "telemetry", not(test)))]
2992                    METRIC_NUM_INITIATED_SESSIONS.increment();
2993
2994                    let surb_estimator_for_rx = surb_estimator.clone();
2995                    let session = HoprSession::new_with_surb_state(
2996                        session_id,
2997                        forward_routing,
2998                        session_config_with(&self.cfg, cfg.capabilities, cfg.max_frames_behind_gap),
2999                        (
3000                            reduced_surb_scoring_sender,
3001                            session_rx.inspect(move |_| {
3002                                // The Exit's SURB signals are recorded in `dispatch_message`, upstream
3003                                // of this: they override the organic SURB gate, and a packet dropped
3004                                // before it reaches here would take the override with it.
3005
3006                                // Received packets = SURB consumption estimate
3007                                // The received packets always consume a single SURB.
3008                                surb_estimator_for_rx
3009                                    .consumed
3010                                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3011                                // Same event, separate counter, on purpose — see `returned_packets`.
3012                                returned_packets.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3013                                #[cfg(feature = "telemetry")]
3014                                telemetry::record_session_surb_consumed(&session_id, 1);
3015                            }),
3016                        ),
3017                        Some(notifier),
3018                        // Entry (sending) side: give flow control the SURB balancer state as its
3019                        // anti-grief down-only ceiling, and the client's opt-in flow-control config.
3020                        Some(surb_mgmt.clone()),
3021                        cfg.flow_control,
3022                    )?;
3023
3024                    #[cfg(feature = "telemetry")]
3025                    initialize_session_telemetry(
3026                        session_id,
3027                        &self.cfg,
3028                        cfg.capabilities,
3029                        Some(&surb_estimator),
3030                        Some(&surb_mgmt),
3031                    );
3032
3033                    slot_guard.commit();
3034                    Ok(session)
3035                } else {
3036                    // Routine for short-lived sessions (health checks, bridges); not a fault.
3037                    tracing::debug!(%session_id, "session ready without SURB balancing");
3038
3039                    // Counted here too, unlike `surb_estimator`: the PIX successor gate reads this,
3040                    // and a knob that binds on some Sessions and not others is how a deposit gate
3041                    // silently becomes a gate that never opens.
3042                    let returned_packets = Arc::new(std::sync::atomic::AtomicU64::new(0));
3043                    let returned_packets_for_rx = returned_packets.clone();
3044
3045                    // Disabled SURB management: a default state has a zero target, which reads as
3046                    // `is_disabled()` and so caps organic SURBs at one per packet — this branch's
3047                    // behaviour, expressed through the same policy the balancing branch uses rather
3048                    // than restated as a literal below.
3049                    let surb_mgmt: Arc<BalancerStateValues> = Default::default();
3050
3051                    // Insert the slot and obtain a guard that rolls it back if any
3052                    // subsequent setup step fails.
3053                    let mut slot_guard = self
3054                        .allocate_session_slot(
3055                            session_id,
3056                            SessionSlot {
3057                                session_tx,
3058                                routing_opts: forward_routing.clone(),
3059                                abort_handles: Arc::new(parking_lot::Mutex::new(abort_handles)),
3060                                surb_mgmt: surb_mgmt.clone(),
3061                                surb_estimator: Default::default(), // No SURB estimator needed
3062                                current_ssa_state,
3063                                // Entry side: the Exit is authoritative for the PIX lifecycle.
3064                                pix_supervisor: Default::default(),
3065                                pix_egress_gate: Default::default(),
3066                                pix_fill: Default::default(),
3067                                returned_packets,
3068                                // Nor does it hold reconstructor state: the live-cycle budget is
3069                                // charged by the side that reconstructs.
3070                                cycle_budget: None,
3071                            },
3072                        )
3073                        .ok_or_else(|| {
3074                            // Session already exists; it means it is most likely a loopback attempt
3075                            error!(%session_id, "session already exists - loopback attempt");
3076                            SessionManagerError::Loopback
3077                        })?;
3078
3079                    #[cfg(all(feature = "telemetry", not(test)))]
3080                    METRIC_NUM_INITIATED_SESSIONS.increment();
3081
3082                    // For standard Session data we first reduce the number of SURBs we want to produce,
3083                    // unless requested to always max them out
3084                    let max_surbs_in_packet = cfg.max_surbs_per_data_packet;
3085                    let surb_mgmt_for_tx = surb_mgmt.clone();
3086                    let reduced_surb_sender =
3087                        msg_sender.with(move |(routing, mut data): (DestinationRouting, ApplicationDataOut)| {
3088                            cap_organic_surbs(&mut data, max_surbs_in_packet, &surb_mgmt_for_tx);
3089                            futures::future::ok::<_, S::Error>((routing, data))
3090                        });
3091
3092                    let session = HoprSession::new(
3093                        session_id,
3094                        forward_routing,
3095                        session_config_with(&self.cfg, cfg.capabilities, cfg.max_frames_behind_gap),
3096                        (
3097                            reduced_surb_sender,
3098                            session_rx.inspect(move |_| {
3099                                returned_packets_for_rx.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3100                            }),
3101                        ),
3102                        Some(notifier),
3103                    )?;
3104
3105                    #[cfg(feature = "telemetry")]
3106                    initialize_session_telemetry(session_id, &self.cfg, cfg.capabilities, None, None);
3107
3108                    slot_guard.commit();
3109                    Ok(session)
3110                }
3111            }
3112            Ok(Ok(None)) => {
3113                self.session_initiations.remove(&challenge);
3114                Err(SessionManagerError::other(anyhow!(
3115                    "internal error: sender has been closed without completing the session establishment"
3116                ))
3117                .into())
3118            }
3119            Ok(Err(error)) => {
3120                // The other side did not allow us to establish a session
3121                let challenge = match error.identifier {
3122                    ErrorIdentifier::Challenge(c) => c,
3123                    ErrorIdentifier::SessionId(_) => {
3124                        // This arm should only ever receive pre-establishment errors.
3125                        // Log the id if it matters for debugging.
3126                        0
3127                    }
3128                };
3129                error!(
3130                    %challenge, ?error,
3131                    "the other party rejected the session initiation with error"
3132                );
3133                Err(TransportSessionError::Rejected(error.reason))
3134            }
3135            Err(_) => {
3136                // Timeout waiting for a session establishment
3137                error!(challenge, "session initiation attempt timed out");
3138
3139                #[cfg(all(feature = "telemetry", not(test)))]
3140                METRIC_RECEIVED_SESSION_ERRS.increment(&["timeout"]);
3141
3142                self.session_initiations.remove(&challenge);
3143                Err(TransportSessionError::Timeout)
3144            }
3145        }
3146    }
3147
3148    /// Sends a keep-alive packet with the given [`SessionId`].
3149    ///
3150    /// This currently "fires & forgets" and does not expect nor await any "pong" response.
3151    pub async fn ping_session(&self, id: &SessionId) -> errors::Result<()> {
3152        if let Some(session_data) = self.sessions.get(id) {
3153            trace!(session_id = ?id, "pinging manually session");
3154            let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3155            send_via_msg_sender(
3156                &mut msg_sender,
3157                session_data.routing_opts.clone(),
3158                HoprStartProtocol::KeepAlive((*id).into()),
3159                "session ping message",
3160            )
3161            .await
3162            .map_err(TransportSessionError::packet_sending)
3163        } else {
3164            Err(SessionManagerError::NonExistingSession.into())
3165        }
3166    }
3167
3168    /// Asks the deposit pool for the data to attach to a batch of SSAs, and collects its answers.
3169    async fn request_deposit_data(
3170        pix_toolbox: &PixToolbox,
3171        session_id: SessionId,
3172        ssa_indices: &[SsaIndex],
3173    ) -> errors::Result<HoprPixDepositData> {
3174        let deposit_ids = ssa_indices
3175            .iter()
3176            .map(|&ssa_index| PixAddressId::new(&session_id, ssa_index))
3177            .collect::<Vec<_>>();
3178
3179        let (deposit_data_created, deposit_data_rx) = futures::channel::mpsc::channel(ssa_indices.len().max(1));
3180
3181        if pix_toolbox
3182            .pix_events
3183            .try_send(HoprSessionOutPixEvent::DepositDataRequest(PixDepositDataRequest {
3184                deposit_ids,
3185                deposit_data_created,
3186            }))
3187            .is_err()
3188        {
3189            return Err(SessionManagerError::MissingDepositData(format!(
3190                "session {session_id} has no listener for the deposit data request"
3191            ))
3192            .into());
3193        }
3194
3195        let mut outstanding = ssa_indices
3196            .iter()
3197            .map(|&ssa_index| PixAddressId::new(&session_id, ssa_index))
3198            .collect::<std::collections::HashSet<_>>();
3199        let requested = outstanding.len();
3200
3201        let mut deposit_data = HoprPixDepositData::with_capacity(requested);
3202        {
3203            let replies = deposit_data_rx
3204                .take(2 * requested)
3205                .take_until(futures_time::task::sleep(futures_time::time::Duration::from(
3206                    DEPOSIT_DATA_REQUEST_TIMEOUT,
3207                )));
3208            futures::pin_mut!(replies);
3209            while let Some(entry) = replies.next().await {
3210                outstanding.remove(&entry.id);
3211                deposit_data.push(entry);
3212                if outstanding.is_empty() {
3213                    break;
3214                }
3215            }
3216        }
3217
3218        // Counted here rather than left to `deposit_data_for_batch`, which sees the same shortfall
3219        // but cannot tell a pool that went quiet from one that answered about the wrong SSAs. Both
3220        // are fatal; only one of them is fixed by making the pool faster.
3221        if !outstanding.is_empty() {
3222            return Err(SessionManagerError::MissingDepositData(format!(
3223                "session {session_id} got {} of {requested} requested deposit data entries within \
3224                 {DEPOSIT_DATA_REQUEST_TIMEOUT:?} ({} replies read in total)",
3225                requested - outstanding.len(),
3226                deposit_data.len(),
3227            ))
3228            .into());
3229        }
3230
3231        Ok(deposit_data)
3232    }
3233
3234    /// Spawns the task that carries out one Session's supervisor actions.
3235    ///
3236    /// The supervisor decides; this executes. Keeping the two apart is what lets the state machine
3237    /// be pure and exhaustively tested, and it means every side effect a PIX Session can have is
3238    /// visible in one `match`.
3239    ///
3240    /// The task owns an [`SsaCommitmentGuard`] per SSA it has requested and one Session retirement
3241    /// scope. Terminal actions retire through the frontier before releasing their guard; aborting
3242    /// the task drops every remaining guard and then the Session's compact frontier. No state grows
3243    /// with the number of cycles the Session completed.
3244    fn spawn_pix_action_driver(
3245        &self,
3246        session_id: SessionId,
3247        slot: &SessionSlot,
3248        action_rx: ActionRx,
3249        reply_routing: DestinationRouting,
3250    ) -> hopr_utils::runtime::AbortHandle {
3251        let myself = self.clone();
3252        let gate = slot
3253            .pix_egress_gate
3254            .get()
3255            .cloned()
3256            .expect("the gate is installed before the driver is spawned");
3257        let share_processor = self
3258            .pix_toolbox
3259            .get()
3260            .expect("the PIX toolbox is installed before a PIX action driver is spawned")
3261            .share_processor
3262            .clone();
3263        let retirement_scope = share_processor.begin_retirement_scope(session_id);
3264
3265        hopr_utils::spawn_as_abortable!(async move {
3266            let _retirement_scope = retirement_scope;
3267            // The vector owns each registration until a terminal action explicitly retires it or
3268            // dropping this future releases it during whole-Session teardown.
3269            let mut owned_ssas: Vec<SsaCommitmentGuard<HoprPixSpec>> = Vec::new();
3270
3271            let close_reason = loop {
3272                let Ok(action) = action_rx.recv().await else {
3273                    // The worker is gone without having said why, which is a failure of the
3274                    // supervisor itself rather than of the Session it was watching.
3275                    break Some(SessionPixCloseReason::SupervisorUnavailable);
3276                };
3277
3278                match action {
3279                    SessionPixAction::RequestSsa { ssa_ids, params } => {
3280                        let Some(slot) = myself.sessions.get(&session_id) else {
3281                            // The Session went away underneath us; report the failure so the
3282                            // supervisor stops waiting on an action that can never complete.
3283                            myself
3284                                .report_ssa_request(
3285                                    &session_id,
3286                                    None,
3287                                    SessionPixAction::RequestSsa { ssa_ids, params },
3288                                    false,
3289                                )
3290                                .await;
3291                            continue;
3292                        };
3293                        match myself.send_ssa_request(session_id, &slot, &ssa_ids, params).await {
3294                            Ok(guards) => {
3295                                owned_ssas.extend(guards);
3296                                // One confirmation per index: the supervisor arms each cycle's
3297                                // commitment deadline on its own `SsaRequestSent`, and they were all
3298                                // put on the wire by the one send that just succeeded.
3299                                if let Some(supervisor) = slot.pix_supervisor.get() {
3300                                    for ssa_id in &ssa_ids {
3301                                        if supervisor
3302                                            .send_event(SessionPixEvent::SsaRequestSent(*ssa_id))
3303                                            .await
3304                                            .is_err()
3305                                        {
3306                                            error!(%session_id, "pix supervisor stopped accepting events");
3307                                            break;
3308                                        }
3309                                    }
3310                                }
3311                            }
3312                            Err(error) => {
3313                                // Every guard was dropped by the early return inside
3314                                // `send_ssa_request`, so the whole batch of indices is released — none
3315                                // of them is left registered for a cycle that will never be asked for.
3316                                error!(
3317                                    %session_id, %error, batch_size = ssa_ids.len(),
3318                                    "failed to send ssa request"
3319                                );
3320
3321                                // A pool that could not supply deposit data ends the Session here
3322                                // rather than reporting the failure onwards. The other failures are
3323                                // transient or already self-correcting, and the supervisor is the
3324                                // right judge of them; this one is neither. The data only ever
3325                                // travels in the `SsaRequest` that carries the commitments, so it
3326                                // cannot be supplied later, and the pool will not answer a retry it
3327                                // did not answer the first time.
3328                                //
3329                                // Named rather than folded into the supervisor's `PixFailure`,
3330                                // because it points at this node's own pool while every reason the
3331                                // supervisor has points at the peer — and the Entry is told with a
3332                                // reason it can act on rather than the generic one.
3333                                if matches!(
3334                                    error,
3335                                    TransportSessionError::Manager(SessionManagerError::MissingDepositData(_))
3336                                ) {
3337                                    myself.stop_pix_fill(&session_id);
3338                                    gate.poison();
3339                                    owned_ssas.clear();
3340                                    myself
3341                                        .notify_session_error(
3342                                            session_id,
3343                                            reply_routing.clone(),
3344                                            StartErrorReason::UnacceptablePixParams,
3345                                            "session error due to missing deposit data",
3346                                        )
3347                                        .await;
3348                                    myself.close_session_with_reason(&session_id, ClosureReason::MissingDepositData);
3349                                    return;
3350                                }
3351
3352                                if let Some(supervisor) = slot.pix_supervisor.get()
3353                                    && supervisor
3354                                        .send_action_result(
3355                                            // Only the discriminant and the ids are read on the
3356                                            // failure path, so the dimensions are echoed back
3357                                            // rather than zeroed — `PixParams` has no zero value,
3358                                            // and inventing one would be a lie about what was sent.
3359                                            SessionPixAction::RequestSsa { ssa_ids, params },
3360                                            false,
3361                                        )
3362                                        .await
3363                                        .is_err()
3364                                {
3365                                    error!(%session_id, "pix supervisor stopped accepting results");
3366                                }
3367                            }
3368                        }
3369                    }
3370                    SessionPixAction::RequestCommitmentRetransmission { ssa_id, params } => {
3371                        // Failure is logged and dropped rather than reported back. Unlike
3372                        // `RequestSsa` this action is not a step the Session depends on: it repeats
3373                        // on its own interval, and the commitment deadline is still the backstop, so
3374                        // a dropped ask costs one interval rather than the Session.
3375                        if let Some(slot) = myself.sessions.get(&session_id)
3376                            && let Err(error) = myself
3377                                .send_commitment_retransmission_request(session_id, &slot, ssa_id, params)
3378                                .await
3379                        {
3380                            error!(%session_id, %ssa_id, %error, "failed to re-request ssa commitment parts");
3381                        }
3382                    }
3383                    SessionPixAction::ReleaseService => {
3384                        #[cfg(feature = "telemetry")]
3385                        crate::telemetry::set_pix_gate_mode(&session_id, true);
3386                    }
3387                    SessionPixAction::WithholdService => {
3388                        #[cfg(feature = "telemetry")]
3389                        crate::telemetry::set_pix_gate_mode(&session_id, false);
3390                    }
3391                    // Gate control is applied synchronously by the supervisor worker. The action
3392                    // reaches this I/O driver only to preserve observability and ordering.
3393                    SessionPixAction::ProgressNotification => {}
3394                    SessionPixAction::SetFillRate(rate) => {
3395                        // Looked up per action rather than captured, because the Session may already
3396                        // be gone: the supervisor plans against state it holds itself, and the slot
3397                        // can be evicted underneath it. A missing slot needs no rate — its stream is
3398                        // already aborted.
3399                        if let Some(slot) = myself.sessions.get(&session_id)
3400                            && let Some(fill) = slot.pix_fill.get()
3401                        {
3402                            fill.set_fill(rate);
3403                            // Inside the guard for the same reason the lookup is: `close_session`
3404                            // removes this Session's metric state and *then* aborts the tasks, and
3405                            // the abort is not synchronous, so a queued `SetFillRate` can still be
3406                            // processed afterwards. Setting the gauge there would re-create the
3407                            // `session_id` label series with nothing left to remove it again.
3408                            #[cfg(feature = "telemetry")]
3409                            crate::telemetry::set_pix_fill_rate(&session_id, rate.as_packets_per_sec());
3410                        }
3411                    }
3412                    SessionPixAction::RetireSsa(ssa_id) => {
3413                        share_processor.retire_ssa(ssa_id);
3414                        owned_ssas.retain(|guard| guard.ssa_id() != Some(&ssa_id));
3415                        if let Some(slot) = myself.sessions.get(&session_id) {
3416                            slot.abort_handles
3417                                .lock()
3418                                .abort_one(&SessionHandles::PixDepositObserver(ssa_id.ssa_index().get()));
3419                        }
3420                    }
3421                    SessionPixAction::Close(reason) => break Some(reason),
3422                }
3423            };
3424
3425            let Some(reason) = close_reason else { return };
3426
3427            // Read before `stop_pix_fill`, which silences the stream either way. Keyed on the drain
3428            // rather than on the reason, which is a strict superset of it: a *stalled* drain closes
3429            // with `RecoveryIdle` or `RecoveryDeadline`, and that is likewise the ordinary end of a
3430            // Session an operator has already closed rather than something to page anyone about.
3431            let draining = myself
3432                .sessions
3433                .get(&session_id)
3434                .is_some_and(|slot| slot.pix_fill.get().is_some_and(|fill| fill.is_draining()));
3435            if draining {
3436                info!(%session_id, %reason, "pix supervisor ended the post-close SURB drain");
3437            } else {
3438                error!(%session_id, %reason, "pix supervisor closed the session");
3439            }
3440
3441            // Unblock anything parked on the gate before tearing down: the supervisor that would
3442            // have woken it is the thing that just stopped. Fill is silenced alongside it, and for a
3443            // sharper reason — the gate holds packets back, while fill would go on *originating*
3444            // them, return-routed to a pseudonym whose SURBs are about to be gone.
3445            myself.stop_pix_fill(&session_id);
3446            gate.poison();
3447            owned_ssas.clear();
3448
3449            #[cfg(feature = "telemetry")]
3450            crate::telemetry::pix::record_pix_closure(reason);
3451
3452            // Return this Session's share of the node-level aggregates now, rather than leaving it
3453            // to `close_session` below. The notification between here and there is a network send
3454            // with its own timeout, and for the whole of it the Session is closed — its supervisor
3455            // has stopped and its gate is poisoned — while `hopr_pix_sessions_active` would still
3456            // be counting it. Idempotent, so `close_session` remains the backstop for every other
3457            // path into it.
3458            if let Some(slot) = myself.sessions.get(&session_id)
3459                && let Some(supervisor) = slot.pix_supervisor.get()
3460            {
3461                supervisor.telemetry.release();
3462            }
3463
3464            // Tell the Entry, so it can drop its side rather than wait out its own timeout. The
3465            // Session is closed either way, so a send failure here changes nothing.
3466            //
3467            // Except after a drain, where the Session the report would be about is already gone and
3468            // the report is return-routed: sending it would spend a SURB out of a buffer that may
3469            // already be empty, which is precisely the origination this whole path exists to avoid.
3470            // It would also be new behaviour rather than preserved behaviour, since an Exit-side
3471            // close sends the Entry nothing today — and `draining` is set before the supervisor's
3472            // verdict, so this covers a refused drain as well as a completed one.
3473            if !draining {
3474                myself.notify_pix_failure(session_id, reply_routing).await;
3475            }
3476
3477            if let Some(slot) = myself.sessions.remove(&session_id) {
3478                myself.active_sessions.fetch_sub(1, Ordering::Relaxed);
3479                close_session(session_id, slot, ClosureReason::PixFailure);
3480            }
3481        })
3482    }
3483
3484    /// Starts the Exit's single Exit → Entry keep-alive stream and installs the handle that steers it.
3485    ///
3486    /// Called for every PIX Session, and for any Session with a configured
3487    /// `surb_balance_notify_period` — the SURB-level notification and PIX fill are the same packet
3488    /// sent for different reasons, so they share one stream and one rate controller. See
3489    /// [`PixFillControl`].
3490    ///
3491    /// Returns without spawning anything when neither producer applies, which is an Exit Session that
3492    /// is not PIX on a node that has switched the notification off. Such a Session originates nothing,
3493    /// which is what it did before this existed.
3494    ///
3495    /// Deliberately not passed through the PIX egress gate. These packets carry no payload; gating
3496    /// them would let an exhausted predeposit budget silence the very signal the Entry needs in order
3497    /// to fund the Session, turning a stall into a teardown. The bound that does apply is
3498    /// [`PixFillControl::admit`]'s SURB reserve, which is about this node's own ability to send at
3499    /// all rather than about what the peer has paid for.
3500    ///
3501    /// `announced_surb_target` is the SURB buffer target the Entry asked for, and it is what that
3502    /// reserve is derived against — see [`effective_surb_reserve`]. It has to be, because the
3503    /// configured reserve is an absolute count while the buffer it is measured against is the Entry's
3504    /// choice; a Session whose Entry asks for a shallower buffer than the configured reserve could
3505    /// otherwise never be filled at all.
3506    fn spawn_exit_keep_alive(
3507        &self,
3508        session_id: SessionId,
3509        slot: &SessionSlot,
3510        msg_sender: S,
3511        is_pix: bool,
3512        announced_surb_target: u64,
3513    ) {
3514        let notify_period = self.cfg.surb_balance_notify_period;
3515        if notify_period.is_none() && !is_pix {
3516            return;
3517        }
3518
3519        let configured_reserve = self.cfg.pix_config.supervision.fill.min_surb_reserve;
3520        let min_surb_reserve = effective_surb_reserve(configured_reserve, announced_surb_target);
3521
3522        // Created here and shared: the sink wrapper below has to hold the same control the stream is
3523        // rate-limited by, so that it can tell a notification packet from a fill packet.
3524        let controller = RateController::new(0, Duration::from_secs(1));
3525        let control = Arc::new(PixFillControl::new(
3526            session_id,
3527            controller.clone(),
3528            notify_period,
3529            min_surb_reserve,
3530            slot.surb_estimator.clone(),
3531        ));
3532
3533        let control_for_admission = control.clone();
3534        let surb_estimator = slot.surb_estimator.clone();
3535        let ka_abort_handle = utils::spawn_keep_alive_stream(
3536            session_id,
3537            // Consumption is counted in the sink rather than at the rate limiter, so it counts what
3538            // was actually sent: a packet the admission predicate dropped consumed no SURB, and
3539            // counting it would walk the Exit's own level estimate down until it starved itself of
3540            // the very reserve that dropped it.
3541            msg_sender.with(move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
3542                surb_estimator
3543                    .consumed
3544                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3545                #[cfg(feature = "telemetry")]
3546                telemetry::record_session_surb_consumed(&session_id, 1);
3547                futures::future::ok::<_, S::Error>((routing, data))
3548            }),
3549            slot.routing_opts.clone(),
3550            SurbNotificationMode::Level(slot.surb_estimator.clone()),
3551            slot.surb_mgmt.clone(),
3552            &controller,
3553            move || control_for_admission.admit(),
3554        );
3555
3556        slot.abort_handles
3557            .lock()
3558            .insert(SessionHandles::KeepAlive, ka_abort_handle);
3559        let _ = slot.pix_fill.set(control.clone());
3560
3561        if let Some(period) = notify_period {
3562            // Delayed by one period, as it always was: a Session that closes immediately should not
3563            // have sent a level report about SURBs it never used. Fill is not held behind this — the
3564            // supervisor's first rate applies the moment it is planned.
3565            //
3566            // Registered so `close_session` can abort it. It is a one-shot, but it holds a clone of
3567            // the control for the whole period, and nothing bounds how long an operator may make that.
3568            let notify_delay = hopr_utils::spawn_as_abortable!(async move {
3569                hopr_utils::runtime::prelude::sleep(period).await;
3570                control.start_notify();
3571            });
3572            slot.abort_handles
3573                .lock()
3574                .insert(SessionHandles::SurbNotifyDelay, notify_delay);
3575        }
3576
3577        // An order of magnitude is the point at which the operator's intent and the Session's shape
3578        // have stopped being about the same deployment. A configured reserve of `R` is a reserve
3579        // sized for a buffer of `4R`; the derivation only lowers it tenfold once the Entry announces
3580        // under `4R / 10`, so this fires exactly on a Session whose buffer is an order of magnitude
3581        // shallower than the one this node was configured against. Fill still runs — a derived
3582        // reserve is strictly better than one that refuses every packet forever — but it is worth
3583        // saying out loud once, when the stream starts, rather than once per withheld packet.
3584        if min_surb_reserve.saturating_mul(10) < configured_reserve {
3585            warn!(
3586                %session_id,
3587                configured = configured_reserve,
3588                effective = min_surb_reserve,
3589                announced_surb_target,
3590                "the PIX fill SURB reserve was lowered by more than an order of magnitude to fit the SURB buffer \
3591                 target the entry announced for this session"
3592            );
3593        }
3594
3595        debug!(
3596            %session_id, ?notify_period, is_pix,
3597            surb_reserve = min_surb_reserve,
3598            announced_surb_target,
3599            "started the exit keep-alive stream"
3600        );
3601    }
3602
3603    /// Silences a Session's PIX fill, leaving its SURB-level notification alone.
3604    ///
3605    /// Called on every path that is about to tear a Session down. `close_session` aborts the stream
3606    /// outright, so this is not what stops it — it is what stops the *extra* traffic in the window
3607    /// between a supervisor deciding the Session is over and the teardown reaching the stream. Every
3608    /// packet originated in that window is return-routed to a pseudonym whose SURBs are gone, and one
3609    /// packet the routing resolver cannot resolve holds up every packet this node originates.
3610    fn stop_pix_fill(&self, session_id: &SessionId) {
3611        if let Some(slot) = self.sessions.get(session_id)
3612            && let Some(fill) = slot.pix_fill.get()
3613        {
3614            fill.set_fill(FillRate::ZERO);
3615        }
3616    }
3617
3618    /// Reports an action outcome to a Session's supervisor, if it still has one.
3619    async fn report_ssa_request(
3620        &self,
3621        session_id: &SessionId,
3622        slot: Option<&SessionSlot>,
3623        action: SessionPixAction,
3624        ok: bool,
3625    ) {
3626        let slot = match slot {
3627            Some(slot) => Some(slot.clone()),
3628            None => self.sessions.get(session_id),
3629        };
3630        if let Some(slot) = slot
3631            && let Some(supervisor) = slot.pix_supervisor.get()
3632            && supervisor.send_action_result(action, ok).await.is_err()
3633        {
3634            error!(%session_id, "pix supervisor stopped accepting results");
3635        }
3636    }
3637
3638    /// Best-effort `SessionError` for an already-established Session, identified by its `SessionId`.
3639    ///
3640    /// Best-effort in the strict sense: the outcome is logged and discarded. Every caller is on a path
3641    /// that has already decided the Session is over, so there is nothing a failed send could change —
3642    /// and the peer has a deadline of its own as the backstop either way. What the notice buys is
3643    /// promptness and attribution: the peer tears down in about a round trip, with the reason in its
3644    /// log, instead of waiting out a timer that names the timer rather than the cause.
3645    ///
3646    /// The peer's `handle_session_error` closes the Session on a `SessionId`-identified error and sends
3647    /// nothing back, so there is no error exchange to loop.
3648    async fn notify_session_error(
3649        &self,
3650        session_id: SessionId,
3651        routing: DestinationRouting,
3652        reason: StartErrorReason,
3653        context: &'static str,
3654    ) {
3655        let Some(mut msg_sender) = self.msg_sender.get().cloned() else {
3656            warn!(%session_id, %reason, context, "cannot send session error - manager not started");
3657            return;
3658        };
3659        match send_via_msg_sender(
3660            &mut msg_sender,
3661            routing,
3662            HoprStartProtocol::SessionError(StartErrorType {
3663                identifier: ErrorIdentifier::SessionId(session_id),
3664                reason,
3665            }),
3666            context,
3667        )
3668        .await
3669        {
3670            Ok(()) => {
3671                #[cfg(all(feature = "telemetry", not(test)))]
3672                METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
3673            }
3674            Err(error) => warn!(%session_id, %reason, %error, context, "failed to send session error"),
3675        }
3676    }
3677
3678    /// Best-effort notice to the Entry that this Session died for PIX reasons.
3679    async fn notify_pix_failure(&self, session_id: SessionId, reply_routing: DestinationRouting) {
3680        self.notify_session_error(
3681            session_id,
3682            reply_routing,
3683            StartErrorReason::Unknown,
3684            "session error after pix failure",
3685        )
3686        .await;
3687    }
3688
3689    /// Registers an Exit commitment for every index in `ssa_indices` and asks the Entry to commit to
3690    /// the matching SSAs — all in a single [`SsaServerCommitmentMessage`].
3691    ///
3692    /// Driven by the supervisor's [`RequestSsa`](SessionPixAction::RequestSsa) action, which is what
3693    /// allocates the indices and fixes the dimensions — this only carries it out. Taking them as
3694    /// arguments rather than re-reading them from the slot keeps one source of truth for what was
3695    /// negotiated: the commitments registered here and the parameters the message advertises cannot
3696    /// disagree with what the supervisor is timing.
3697    ///
3698    /// One message for the whole batch, and one `params` field covering all of it, which is correct:
3699    /// every SSA in a Session uses the same negotiated dimensions. The Entry enforces its own ceiling
3700    /// on how many it will accept ([`SessionManagerConfig::max_ssas_per_ssa_request`]) and refuses an
3701    /// over-cap batch in full, so a batch larger than the peer allows loses the Session — see
3702    /// [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request).
3703    ///
3704    /// Installs no deadline of its own: every timeout that used to be armed here now belongs to the
3705    /// supervisor, which can see the whole cycle rather than just this one step.
3706    ///
3707    /// The returned [`SsaCommitmentGuard`]s own their registrations until the caller transfers or drops
3708    /// them. Handing them out rather than retaining them here is what makes the failure path safe:
3709    /// this function has fallible steps after the registrations exist — including partway through the
3710    /// batch — and an early return from any of them releases every one of them. That matters more for
3711    /// a batch than for a single SSA: without it one failed send would strand every index it had
3712    /// registered, and since the supervisor does not reuse an index, the Session could never recover.
3713    async fn send_ssa_request(
3714        &self,
3715        session_id: SessionId,
3716        slot: &SessionSlot,
3717        ssa_ids: &[SsaId<HoprPseudonym>],
3718        params: PixParams,
3719    ) -> errors::Result<Vec<SsaCommitmentGuard<HoprPixSpec>>> {
3720        let Some(first_ssa_id) = ssa_ids.first().copied() else {
3721            return Err(SessionManagerError::Other(anyhow!("ssa request with no indices")).into());
3722        };
3723
3724        let pix_toolbox = self.pix_toolbox.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3725        // Clone kept on this task: `pix_toolbox` itself is moved into the blocking commitment task,
3726        // while the deposit-data request below needs it here.
3727        let pix_toolbox_local = pix_toolbox.clone();
3728        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3729
3730        // One blocking task for the whole batch rather than one per SSA: each commitment is a single
3731        // random scalar and one generator multiplication, so per-task overhead would dominate.
3732        let ids = ssa_ids.to_vec();
3733        let (exit_commitments, guards) = hopr_utils::parallelize::cpu::spawn_blocking(
3734            move || {
3735                let mut commitments = Vec::with_capacity(ids.len());
3736                let mut guards = Vec::with_capacity(ids.len());
3737                for ssa_id in ids {
3738                    let (commitment, guard) = pix_toolbox
3739                        .share_processor
3740                        .new_guarded_exit_commitment(ssa_id, params)?;
3741                    commitments.push((ssa_id.ssa_index(), HoprPixGroupElement(commitment.to_bytes())));
3742                    guards.push(guard);
3743                }
3744                Ok::<_, hopr_protocol_pix::errors::PixError<HoprPseudonym>>((commitments, guards))
3745            },
3746            "server_ssa_commitment",
3747        )
3748        .await
3749        .map_err(SessionManagerError::other)?
3750        .map_err(SessionManagerError::PixError)?;
3751
3752        info!(
3753            %session_id, batch_size = ssa_ids.len(), %params, %first_ssa_id,
3754            "generated exit commitments for the SSA batch"
3755        );
3756
3757        // Ask the deposit pool for the data to attach to this batch, and keep what it produced: the
3758        // Exit needs it twice. Once now, to put on the wire, and once per SSA later, to hand back to
3759        // the pool with the deposit address in `DepositNeeded` — by which point the batch it came
3760        // from is long gone.
3761        //
3762        // A pool that does not answer fails the batch here, which drops `guards` and releases every
3763        // registration it made. That is the only safe direction: deposit data reaches the Entry in
3764        // this message or not at all, so a batch sent without it buys service the Entry can never
3765        // pay for.
3766        let ssa_indices = ssa_ids.iter().map(|ssa_id| ssa_id.ssa_index()).collect::<Vec<_>>();
3767        let deposit_data = Self::request_deposit_data(&pix_toolbox_local, session_id, &ssa_indices).await?;
3768
3769        if !self.sessions.contains_key(&session_id) {
3770            return Err(SessionManagerError::NonExistingSession.into());
3771        }
3772
3773        let deposit_data = deposit_data_for_batch(&session_id, &ssa_indices, deposit_data)?;
3774        if let Some(ssa_state) = slot.current_ssa_state.get() {
3775            ssa_state.remember_sent_deposit_data(&deposit_data);
3776        }
3777
3778        // Construct and send the Exit SSA commitment request message.
3779        // The parameters were previously verified to be acceptable.
3780        let data = HoprStartProtocol::SsaRequest(SsaServerCommitmentMessage::new(
3781            session_id,
3782            params,
3783            exit_commitments,
3784            deposit_data,
3785        ));
3786
3787        send_via_msg_sender(
3788            &mut msg_sender,
3789            slot.routing_opts.clone(),
3790            data,
3791            "session SSA commitment request message",
3792        )
3793        .await
3794        .map_err(TransportSessionError::packet_sending)?;
3795
3796        Ok(guards)
3797    }
3798
3799    /// Asks the Entry to re-send the parts of one SSA's commitment that never arrived.
3800    ///
3801    /// The Entry's answer to an `SsaRequest` is hundreds of packets and the reconstructor can use
3802    /// none of it until every one has landed, so a single drop used to cost the Session on
3803    /// `max_ssa_delivery_time` — after the Entry had already been told to fund the cycle. This is the
3804    /// repair: the reconstructor says which polynomial runs are missing, and the Entry re-sends
3805    /// exactly those.
3806    ///
3807    /// Deliberately *not* built on [`send_ssa_request`](Self::send_ssa_request), whose every step is
3808    /// wrong here. It registers no Exit commitment — the cycle already has one, and registering a
3809    /// second for the same index is refused as a duplicate — allocates no index, and asks the deposit
3810    /// pool for nothing, since the deposit data of this cycle travelled with the request that created
3811    /// it. Nothing it does can earn the Entry a deposit, which is what makes it safe to repeat.
3812    ///
3813    /// The scope is read here rather than carried in the action: what is missing changes as the burst
3814    /// continues to arrive, and the freshest answer is both smaller and the one that converges.
3815    async fn send_commitment_retransmission_request(
3816        &self,
3817        session_id: SessionId,
3818        slot: &SessionSlot,
3819        ssa_id: SsaId<HoprPseudonym>,
3820        params: PixParams,
3821    ) -> errors::Result<()> {
3822        let pix_toolbox = self.pix_toolbox.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3823        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3824
3825        // Asked of the encoder, so the scope cannot be sized against a restated layout — see
3826        // `StartProtocol::max_missing_runs`.
3827        let max_runs = HoprStartProtocol::max_missing_runs(&session_id).map_err(SessionManagerError::other)?;
3828
3829        let Some(runs) = pix_toolbox.share_processor.missing_commitment_runs(&ssa_id, max_runs) else {
3830            // The registration is gone — retired, or expired after
3831            // `incomplete_commitment_lifetime` — so there is nothing left for a retransmission to
3832            // complete. The commitment deadline is what ends the Session; saying so here is the only
3833            // way an operator learns which of the two it was.
3834            warn!(%session_id, %ssa_id, "cannot re-request commitment parts for a cycle the reconstructor no longer holds");
3835            return Ok(());
3836        };
3837
3838        if runs.is_empty() {
3839            // The commitment completed between the timer firing and this running. Its
3840            // `CommitmentVerified` is already on its way to the supervisor, which clears the timer.
3841            debug!(%session_id, %ssa_id, "commitment completed before its parts could be re-requested");
3842            return Ok(());
3843        }
3844
3845        let missing_polynomials: u32 = runs.iter().map(|(first, last)| (last - first) as u32 + 1).sum();
3846        info!(
3847            %session_id, %ssa_id, runs = runs.len(), missing_polynomials,
3848            "re-requesting the SSA commitment parts that did not arrive"
3849        );
3850
3851        send_via_msg_sender(
3852            &mut msg_sender,
3853            slot.routing_opts.clone(),
3854            HoprStartProtocol::SsaRequest(SsaServerCommitmentMessage::recommit(
3855                session_id,
3856                params,
3857                [(ssa_id.ssa_index(), runs)],
3858            )),
3859            "ssa commitment retransmission request",
3860        )
3861        .await
3862        .map_err(TransportSessionError::packet_sending)?;
3863
3864        Ok(())
3865    }
3866
3867    /// Returns the current number of active sessions.
3868    pub fn num_active_sessions(&self) -> usize {
3869        self.active_sessions.load(Ordering::Relaxed)
3870    }
3871
3872    /// Returns [`SessionIds`](SessionId) of all currently active sessions.
3873    pub fn active_sessions(&self) -> Vec<SessionId> {
3874        self.sessions.run_pending_tasks();
3875        self.sessions.iter().map(|(k, _)| *k).collect()
3876    }
3877
3878    /// Explicitly closes the session with the given `id`.
3879    ///
3880    /// Removes the entry from the internal session cache, closes the data channel,
3881    /// and aborts any auxiliary tasks. Returns `true` if a session was found and
3882    /// closed, `false` otherwise.
3883    ///
3884    /// This avoids waiting for the idle timeout (`time_to_idle`) or the LRU
3885    /// capacity bound to evict the entry, which is the desired behaviour when
3886    /// the caller (e.g. REST `DELETE /session`) knows the session is finished.
3887    ///
3888    /// One case departs from that: an Exit-side PIX Session with
3889    /// [`fill.drain_after_close`](crate::PixFillConfig::drain_after_close) on and spendable SURBs
3890    /// left starts a SURB drain instead of being torn down (see `begin_surb_drain`). The data path
3891    /// still ends at once, but the session slot and the keep-alive stream stay in place until the
3892    /// supervisor closes the Session — whether because the funded cycle recovered or because the
3893    /// drain was refused — and `true` then means "found, and now draining" rather than "found and
3894    /// closed".
3895    pub fn close_session(&self, id: &SessionId) -> bool {
3896        self.close_session_with_reason(id, ClosureReason::Eviction)
3897    }
3898
3899    /// Answers an Exit-side close by draining the Session's buffered SURBs, when that is worth doing.
3900    ///
3901    /// A PIX Session can end long before the cycle it was funded for does. The deposit that cycle was
3902    /// paid is released only once its whole emission has ridden back to the Entry, and the address it
3903    /// sits at derives from both nodes' commitments — so tearing the Session down mid-cycle strands
3904    /// money both sides have already parted with. When the Exit still holds enough SURBs to finish,
3905    /// it keeps the keep-alive stream running instead, and the Session's slot with it, until the
3906    /// supervisor closes the Session for good.
3907    ///
3908    /// Returns `None` when this close was **not** answered here, which is every case the caller must
3909    /// handle exactly as it always has; `Some(v)` is the value
3910    /// [`close_session_with_reason`](Self::close_session_with_reason) should return. `Some` currently
3911    /// always carries `true` — a drain is only ever started for a Session that was found — but the
3912    /// value is returned rather than assumed so that the two outcomes stay distinguishable if a
3913    /// future refusal path wants to report one.
3914    ///
3915    /// The order of the checks is load-bearing:
3916    ///
3917    /// 1. the configuration, so that with `drain_after_close` (or `fill.enabled`) off nothing below runs and every
3918    ///    close path is byte-for-byte what it was;
3919    /// 2. the slot, by `get` rather than `remove` — the lookup also refreshes the idle timer the drain lives on;
3920    /// 3. an Exit-side PIX Session, so an Entry-side or non-PIX Session falls straight through;
3921    /// 4. an already-draining Session: a `WriteClosed`/`EmptyRead` is the end-of-stream our own ingress abort produces
3922    ///    and is absorbed, while anything else — a second operator close, or a peer `SessionError` — forces the hard
3923    ///    teardown, which is the operator's way to overrule a drain;
3924    /// 5. a PIX failure already tearing the Session down, which nothing here should interfere with;
3925    /// 6. the SURB estimate, which is the one half of the predicate this layer can answer — see the comment on it for
3926    ///    why answering it here rather than leaving it all to the supervisor matters;
3927    /// 7. and only then the stream is put into drain mode, **before** the supervisor is told, so that no packet can be
3928    ///    classified as the reserve-exempt notification in the window between the supervisor deciding and the stream
3929    ///    learning.
3930    ///
3931    /// What it does *not* do is decrement `active_sessions` or remove the slot: the supervisor's own
3932    /// `Close` teardown does both, whether the drain completes or is refused a channel hop later.
3933    /// `Balancer`, `KeepAlive`, `SurbNotifyDelay`, `PixActionDriver` and the deposit observers are
3934    /// all left running; only the ingress is aborted, so the session server sees its end of the
3935    /// stream as promptly as it would have.
3936    fn begin_surb_drain(&self, id: &SessionId, reason: ClosureReason) -> Option<bool> {
3937        let fill_cfg = &self.cfg.pix_config.supervision.fill;
3938        if !(fill_cfg.enabled && fill_cfg.drain_after_close) {
3939            return None;
3940        }
3941
3942        let slot = self.sessions.get(id)?;
3943        let supervisor = slot.pix_supervisor.get()?;
3944        let fill = slot.pix_fill.get()?;
3945
3946        if fill.is_draining() {
3947            return answer_while_draining(reason);
3948        }
3949
3950        if matches!(reason, ClosureReason::PixFailure | ClosureReason::MissingDepositData) {
3951            return None;
3952        }
3953
3954        // Nothing to drain *with* is decidable here, and cheaply, unlike whether there is anything
3955        // worth draining *for* — which needs the cycle state only the supervisor holds. Asking it
3956        // anyway would defer the teardown of every PIX Session by however long the action driver
3957        // takes to reach the answer, and a driver mid-`send_ssa_request` can be seconds away from
3958        // that. So a Session whose estimate is already at or below its reserve is torn down here and
3959        // now, exactly as it always was: no rate the supervisor could plan would put a packet on the
3960        // wire against a buffer that shallow.
3961        let drainable_surbs = fill.drainable_surbs();
3962        if drainable_surbs == 0 {
3963            return None;
3964        }
3965
3966        // The check above is only a fast path: `close_session_with_reason` is reached from the
3967        // operator, the closure notifier and `handle_session_error` independently, so two closes can
3968        // both read it as false. The transition is what actually decides, and the loser is answered
3969        // as it would have been had the two been serialized rather than concurrent — which is what
3970        // keeps the supervisor from being told a Session closed twice.
3971        if !fill.enter_drain() {
3972            return answer_while_draining(reason);
3973        }
3974
3975        if !supervisor.try_send_event(SessionPixEvent::SessionClosed { drainable_surbs }) {
3976            return None;
3977        }
3978
3979        // The data path ends here regardless of what the supervisor decides: a writer parked on the
3980        // egress gate is waiting for a Session that has gone, and the reader's end-of-stream is what
3981        // the session server is waiting for.
3982        if let Some(gate) = slot.pix_egress_gate.get() {
3983            gate.poison();
3984        }
3985        slot.abort_handles.lock().abort_one(&SessionHandles::Ingress);
3986
3987        #[cfg(feature = "telemetry")]
3988        set_session_state(id, SessionLifecycleState::Closing);
3989
3990        info!(
3991            session_id = %id,
3992            ?reason,
3993            drainable_surbs,
3994            "session closed at the exit — draining its buffered SURBs into the funded cycle"
3995        );
3996        Some(true)
3997    }
3998
3999    /// [`close_session`](Self::close_session) with the reason spelled out.
4000    ///
4001    /// The reason is what the operator reads when a PIX Session stops, and the failures are hard to
4002    /// tell apart from the outside, so a caller that knows which one it is says so.
4003    ///
4004    /// `true` means the Session was found and dealt with. Since
4005    /// [`begin_surb_drain`](Self::begin_surb_drain), "dealt with" also covers "found, and now
4006    /// draining its remaining SURBs into a funded cycle" — in which case the slot is deliberately
4007    /// left in place until the supervisor's own teardown removes it.
4008    fn close_session_with_reason(&self, id: &SessionId, reason: ClosureReason) -> bool {
4009        if let Some(handled) = self.begin_surb_drain(id, reason) {
4010            return handled;
4011        }
4012
4013        if let Some(slot) = self.sessions.remove(id) {
4014            self.active_sessions.fetch_sub(1, Ordering::Relaxed);
4015            // Reconstructor state is released by `close_session` aborting the PIX action driver,
4016            // whose commitment guards retire on drop.
4017            close_session(*id, slot, reason);
4018            true
4019        } else {
4020            false
4021        }
4022    }
4023
4024    /// Updates the configuration of the SURB balancer on the given [`SessionId`].
4025    ///
4026    /// Returns an error if the Session with the given `id` does not exist, or
4027    /// if it does not use SURB balancing.
4028    pub fn update_surb_balancer_config(&self, id: &SessionId, config: SurbBalancerConfig) -> errors::Result<()> {
4029        let cfg = self
4030            .sessions
4031            .get(id)
4032            .ok_or(SessionManagerError::NonExistingSession)?
4033            .surb_mgmt;
4034
4035        // Only update the config if there already was one before
4036        if !cfg.is_disabled() {
4037            cfg.update(&config);
4038            Ok(())
4039        } else {
4040            Err(SessionManagerError::other(anyhow!("session does not use SURB balancing")).into())
4041        }
4042    }
4043
4044    /// Retrieves the configuration of SURB balancing for the given Session.
4045    ///
4046    /// Returns an error if the Session with the given `id` does not exist.
4047    pub fn get_surb_balancer_config(&self, id: &SessionId) -> errors::Result<Option<SurbBalancerConfig>> {
4048        match self.sessions.get(id) {
4049            Some(session) => Ok(Some(session.surb_mgmt.as_ref())
4050                .filter(|c| !c.is_disabled())
4051                .map(|d| d.as_config())),
4052            None => Err(SessionManagerError::NonExistingSession.into()),
4053        }
4054    }
4055
4056    /// Gets estimations produced/received and consumed SURBs by the Session.
4057    ///
4058    /// For an outgoing Session (Entry) the pair is the number of SURBs sent (by us) and used (by the Exit).
4059    /// For an incoming Session (Exit) the pair is the number of SURBs received (from Entry) and used (by us).
4060    ///
4061    /// Returns an error if the Session with the given `id` does not exist.
4062    pub fn get_surb_level_estimates(&self, id: &SessionId) -> errors::Result<(u64, u64)> {
4063        match self.sessions.get(id) {
4064            Some(session) => Ok((
4065                session
4066                    .surb_estimator
4067                    .produced
4068                    .load(std::sync::atomic::Ordering::Relaxed),
4069                session
4070                    .surb_estimator
4071                    .consumed
4072                    .load(std::sync::atomic::Ordering::Relaxed),
4073            )),
4074            None => Err(SessionManagerError::NonExistingSession.into()),
4075        }
4076    }
4077
4078    /// Forwards a PIX protocol observation to the Session's supervisor.
4079    ///
4080    /// The manager no longer interprets these. Deciding what an early-recovery signal or a failed
4081    /// share means for a Session's lifecycle needs the whole picture — which SSAs are in flight,
4082    /// what phase each is in, how much service has been consumed — and that lives in the supervisor.
4083    ///
4084    /// A `NonExistingSession` error is expected traffic rather than a fault: acknowledgements
4085    /// outlive their Session by up to the reconstructor's ack window, so events for a just-closed
4086    /// Session are routine. Callers distinguish it for exactly that reason.
4087    pub async fn dispatch_pix_event(&self, event: HoprSessionInPixEvent) -> errors::Result<()> {
4088        // Copied rather than borrowed, so the match below can move the event's payload out.
4089        let session_id = *event.pseudonym();
4090        let Some(slot) = self.sessions.get(&session_id) else {
4091            debug!(%session_id, "pix event for a session that is no longer registered");
4092            return Err(SessionManagerError::NonExistingSession.into());
4093        };
4094
4095        let Some(supervisor) = slot.pix_supervisor.get() else {
4096            // Entry side, or a Session that negotiated no PIX: nothing supervises it, so there is
4097            // nothing an observation could act on.
4098            trace!(%session_id, "pix event on a session without a supervisor");
4099            return Ok(());
4100        };
4101
4102        // Progress is the one input whose rate is set by traffic rather than by lifecycle
4103        // transitions, so it is delivered without backpressure — awaiting channel capacity here
4104        // would put the supervisor's scheduling latency on the acknowledgement path. Safe by
4105        // construction rather than by tolerance: snapshots carry absolute counters and the state
4106        // machine keeps the maximum it has seen, so a dropped one is indistinguishable from a late
4107        // one, and the next one supersedes it.
4108        if let HoprSessionInPixEvent::RecoveryProgress(progress) = event {
4109            #[cfg(feature = "telemetry")]
4110            telemetry::set_pix_recovery_progress(&session_id, progress.useful_shares, progress.target_useful_shares);
4111
4112            if !supervisor.try_send_progress(progress) {
4113                trace!(%session_id, "dropped a pix progress snapshot on a full supervisor channel");
4114            }
4115            return Ok(());
4116        }
4117
4118        let sent = match event {
4119            HoprSessionInPixEvent::SsaAlmostRecovered(ssa_id) => {
4120                supervisor.send_event(SessionPixEvent::AlmostRecovered(ssa_id)).await
4121            }
4122            HoprSessionInPixEvent::SsaRecovered(ssa_id) => {
4123                supervisor.send_event(SessionPixEvent::Recovered(ssa_id)).await
4124            }
4125            HoprSessionInPixEvent::UnverifiableShares {
4126                ssa_id,
4127                observed_total,
4128                peer,
4129            } => {
4130                supervisor
4131                    .send_event(SessionPixEvent::UnverifiableShares {
4132                        ssa_id,
4133                        observed_total,
4134                        peer,
4135                    })
4136                    .await
4137            }
4138            HoprSessionInPixEvent::RecoveryProgress(_) => unreachable!("handled above"),
4139        };
4140
4141        if sent.is_err() {
4142            error!(%session_id, "pix supervisor is no longer accepting events");
4143        }
4144
4145        Ok(())
4146    }
4147
4148    /// Marks the return path to `destination` as degraded on every Session routed there.
4149    ///
4150    /// The Session layer cannot tell a dead return path from a peer with nothing to say -- both
4151    /// simply stop consuming SURBs -- so the judgement is made where sibling paths can be compared
4152    /// and delivered here. Sessions that did not opt in ignore the mark; the rest stop trusting
4153    /// their counterparty buffer estimate for `grace`.
4154    ///
4155    /// Returns the pseudonyms of the Sessions that were marked (empty when nothing routes there).
4156    ///
4157    /// The caller uses these to advance each Session's SURB generation: the return path to
4158    /// `destination` has just been re-planned, so the fresh SURB batch must supersede the SURBs the
4159    /// counterparty still holds for the dead path.
4160    pub fn mark_return_path_degraded(
4161        &self,
4162        destination: &hopr_api::types::internal::NodeId,
4163        grace: std::time::Duration,
4164    ) -> Vec<HoprPseudonym> {
4165        self.sessions
4166            .iter()
4167            .filter(|(_, slot)| {
4168                // Only sessions that actually run a return path are affected: with `return_options:
4169                // None` the session mints no return SURBs, so the counterparty holds none to
4170                // supersede and there is nothing to mark or bump.
4171                matches!(
4172                    &slot.routing_opts,
4173                    DestinationRouting::Forward { destination: d, return_options: Some(_), .. }
4174                    if d.as_ref() == destination
4175                )
4176            })
4177            .map(|(pseudonym, slot)| {
4178                slot.surb_mgmt.mark_return_path_degraded(grace);
4179                // The session cache is keyed by pseudonym, which is exactly the SURB-generation key.
4180                *pseudonym.as_ref()
4181            })
4182            .collect()
4183    }
4184
4185    /// The main method to be called whenever data are received.
4186    ///
4187    /// It tries to recognize the message and correctly dispatches either
4188    /// the Session protocol or Start protocol messages.
4189    ///
4190    /// If the data are not recognized, they are returned as [`DispatchResult::Unrelated`].
4191    pub fn dispatch_message(
4192        &self,
4193        pseudonym: HoprPseudonym,
4194        in_data: ApplicationDataIn,
4195    ) -> errors::Result<DispatchResult> {
4196        if in_data.data.application_tag == HoprStartProtocol::START_PROTOCOL_MESSAGE_TAG {
4197            // Start-protocol traffic bypasses `session_rx`, but an Exit → Entry message still
4198            // consumed one return SURB and unlocked one PIX share. Count it on outgoing Sessions
4199            // before handing it to the protocol worker; incoming Sessions receive this traffic from
4200            // the Entry over the forward path and must not be credited.
4201            if let Some(session_slot) = self.sessions.get(&pseudonym)
4202                && matches!(&session_slot.routing_opts, DestinationRouting::Forward { .. })
4203            {
4204                session_slot
4205                    .returned_packets
4206                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4207
4208                // The only place the Exit's SURB supply can be heard from a quiet Session. The
4209                // conversion below takes `in_data.data` and drops the packet info with it, so by the
4210                // time `handle_keep_alive` runs the signals are gone -- and keep-alives are all a
4211                // Session that has stopped exchanging data still sends.
4212                session_slot
4213                    .surb_mgmt
4214                    .observe_counterparty_signals(in_data.packet_info.signals_from_sender);
4215            }
4216
4217            // This is a Start protocol message, so we send it to the handler
4218            trace!("dispatching Start protocol message");
4219            if let Some(start_protocol_tx) = self.start_protocol_tx.get() {
4220                start_protocol_tx
4221                    .try_send((pseudonym, HoprStartProtocol::try_from(in_data.data)?))
4222                    .map_err(|error| {
4223                        error!(%error, "failed to send Start protocol message to processing task");
4224                        SessionManagerError::other(error)
4225                    })?;
4226            } else {
4227                return Err(SessionManagerError::NotStarted.into());
4228            }
4229
4230            #[cfg(all(feature = "telemetry", not(test)))]
4231            METRIC_DISPATCHED_MSGS.increment_by(&["processed"], 1);
4232
4233            return Ok(DispatchResult::Processed);
4234        } else if in_data.data.application_tag == SESSION_APPLICATION_TAG {
4235            // This is traffic that belongs to one of the Sessions
4236            let session_id = pseudonym;
4237
4238            // How often a sustained inbox-full condition is allowed to log. Backpressure can
4239            // affect every packet on the hot path, so warning per drop would reproduce exactly
4240            // the ERROR spam this dispatch was rewritten to avoid — warn once per interval instead.
4241            const SESSION_INBOX_FULL_WARN_INTERVAL: usize = 256;
4242
4243            return if let Some(session_slot) = self.sessions.get(&session_id) {
4244                trace!(%session_id, "received data for a registered session");
4245
4246                // Recorded here rather than once the Session reads the packet, because the send
4247                // below can drop it for local backpressure and the reader would never see it. What
4248                // the counterparty says about its own SURB supply is the override on the organic
4249                // SURB gate, so losing it to a full inbox would leave production shut off on an
4250                // estimate the counterparty has just contradicted.
4251                if matches!(&session_slot.routing_opts, DestinationRouting::Forward { .. }) {
4252                    session_slot
4253                        .surb_mgmt
4254                        .observe_counterparty_signals(in_data.packet_info.signals_from_sender);
4255                }
4256
4257                match session_slot.session_tx.try_send(in_data) {
4258                    Ok(_) => {
4259                        #[cfg(all(feature = "telemetry", not(test)))]
4260                        METRIC_DISPATCHED_MSGS.increment_by(&["processed"], 1);
4261
4262                        Ok(DispatchResult::Processed)
4263                    }
4264                    // Benign teardown race: the slot is still registered but its data sink was
4265                    // dropped. Quiet on purpose — see [`DropReason`].
4266                    Err(crossfire::TrySendError::Disconnected(_)) => {
4267                        trace!(%session_id, "dropping data for a session whose sink has closed");
4268                        crate::counters::SESSION_INBOX_CLOSED_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4269                        #[cfg(all(feature = "telemetry", not(test)))]
4270                        METRIC_DISPATCHED_MSGS.increment_by(&["dropped_sink_closed"], 1);
4271                        Ok(DispatchResult::Dropped(DropReason::SinkClosed))
4272                    }
4273                    // Genuine backpressure: the reader is not keeping up. Rate-limited so a
4274                    // sustained overload cannot become per-packet log spam.
4275                    Err(crossfire::TrySendError::Full(_)) => {
4276                        let prev =
4277                            crate::counters::SESSION_INBOX_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4278                        if prev.is_multiple_of(SESSION_INBOX_FULL_WARN_INTERVAL) {
4279                            warn!(%session_id, total_inbox_full_drops = prev + 1, "session inbox full, dropping data (backpressure)");
4280                        }
4281                        #[cfg(all(feature = "telemetry", not(test)))]
4282                        METRIC_DISPATCHED_MSGS.increment_by(&["dropped_sink_full"], 1);
4283                        Ok(DispatchResult::Dropped(DropReason::SinkFull))
4284                    }
4285                }
4286            } else {
4287                // Fully deregistered session: packets still in flight for a torn-down session.
4288                // Quiet — see [`DropReason::Unregistered`].
4289                trace!(%session_id, "dropping data for an unregistered session");
4290                crate::counters::SESSION_UNKNOWN_DATA_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4291                #[cfg(all(feature = "telemetry", not(test)))]
4292                METRIC_DISPATCHED_MSGS.increment_by(&["dropped_unregistered"], 1);
4293                Ok(DispatchResult::Dropped(DropReason::Unregistered))
4294            };
4295        }
4296
4297        trace!(tag = %in_data.data.application_tag, "received data not associated with session protocol or any existing session");
4298
4299        crate::counters::SESSION_UNRELATED_DATA_DISPATCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4300
4301        #[cfg(all(feature = "telemetry", not(test)))]
4302        METRIC_DISPATCHED_MSGS.increment_by(&["unrelated"], 1);
4303
4304        Ok(DispatchResult::Unrelated(in_data))
4305    }
4306
4307    /// Pre-populates the sessions cache with a session slot for benchmarking.
4308    ///
4309    /// Intended for benchmarks that need a session to exist before calling
4310    /// [`SessionManager::dispatch_message`].
4311    ///
4312    /// Requires the `"benchmark"` feature (or a test build).
4313    #[cfg(any(feature = "benchmark", test))]
4314    pub fn pre_populate_session(&self, session_id: SessionId, routing_opts: DestinationRouting) {
4315        let (session_tx, _) =
4316            crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
4317        let slot = SessionSlot {
4318            session_tx,
4319            routing_opts,
4320            abort_handles: Default::default(),
4321            surb_mgmt: Arc::new(BalancerStateValues::default()),
4322            surb_estimator: Default::default(),
4323            current_ssa_state: Default::default(),
4324            pix_supervisor: Default::default(),
4325            pix_egress_gate: Default::default(),
4326            pix_fill: Default::default(),
4327            returned_packets: Default::default(),
4328            // Never PIX, so there is no reconstructor state to charge for.
4329            cycle_budget: None,
4330        };
4331        self.sessions.insert(session_id, slot);
4332        self.slot_allocated
4333            .lock()
4334            .unwrap_or_else(|e| e.into_inner())
4335            .remove(&session_id);
4336    }
4337
4338    /// Like [`pre_populate_session`](SessionManager::pre_populate_session) but also returns the
4339    /// session channel receiver so the caller can spawn a drain task.
4340    ///
4341    /// Requires the `"benchmark"` feature (or a test build).
4342    #[cfg(any(feature = "benchmark", test))]
4343    pub fn pre_populate_session_with_receiver(
4344        &self,
4345        session_id: SessionId,
4346        routing_opts: DestinationRouting,
4347    ) -> crossfire::AsyncRx<crossfire::mpsc::Array<ApplicationDataIn>> {
4348        let (session_tx, session_rx) =
4349            crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
4350        let slot = SessionSlot {
4351            session_tx,
4352            routing_opts,
4353            abort_handles: Default::default(),
4354            surb_mgmt: Arc::new(BalancerStateValues::default()),
4355            surb_estimator: Default::default(),
4356            current_ssa_state: Default::default(),
4357            pix_supervisor: Default::default(),
4358            pix_egress_gate: Default::default(),
4359            pix_fill: Default::default(),
4360            returned_packets: Default::default(),
4361            // Never PIX, so there is no reconstructor state to charge for.
4362            cycle_budget: None,
4363        };
4364        self.sessions.insert(session_id, slot);
4365        self.slot_allocated
4366            .lock()
4367            .unwrap_or_else(|e| e.into_inner())
4368            .remove(&session_id);
4369        session_rx
4370    }
4371
4372    /// Charges `params`' worth of reconstructor state against the node-wide budget.
4373    ///
4374    /// Returns `None` if the node has already committed to as much as
4375    /// [`IncomingSessionPixConfig::max_live_cycle_bytes`] allows — the caller must then refuse the
4376    /// Session, because nothing later in establishment can give the memory back.
4377    ///
4378    /// A CAS loop rather than an unconditional `fetch_add` with a rollback: an add that is
4379    /// provisionally over the ceiling is briefly visible to every concurrent initiation, and with
4380    /// enough of them arriving at once the budget would appear exhausted to Sessions that do fit.
4381    fn reserve_cycle_budget(&self, params: &PixParams, ssas_per_request: usize) -> Option<Arc<CycleBudgetReservation>> {
4382        let bytes = cycle_budget_for(params, ssas_per_request);
4383        let ceiling = self.cfg.pix_config.max_live_cycle_bytes;
4384
4385        self.live_cycle_bytes
4386            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |held| {
4387                held.checked_add(bytes).filter(|total| *total <= ceiling)
4388            })
4389            .ok()
4390            .map(|_| {
4391                // Reports only this reservation's own bytes, not an outstanding total. Initiations
4392                // are processed concurrently, so any total computed here can be published out of
4393                // order with another reservation's; the gauge adds instead, which commutes.
4394                #[cfg(feature = "telemetry")]
4395                crate::telemetry::pix::record_cycle_bytes_reserved(bytes);
4396
4397                Arc::new(CycleBudgetReservation {
4398                    bytes,
4399                    outstanding: self.live_cycle_bytes.clone(),
4400                    released: std::sync::atomic::AtomicBool::new(false),
4401                })
4402            })
4403    }
4404
4405    /// Asks the session server on what terms this Session may be admitted.
4406    ///
4407    /// Fails closed. A refusal, a server that does not answer in time, a full request channel and a
4408    /// dropped reply all end the Session here rather than falling back to the node's own terms:
4409    /// falling back would mean an overloaded or wedged server silently disables every rule the
4410    /// operator wrote, and the peer would be served on terms nobody chose.
4411    ///
4412    /// The two failure kinds are reported differently because they call for different responses. A
4413    /// server that answered `Err` has decided, and re-asking will not change the answer, so the peer
4414    /// is told [`StartErrorReason::TargetNotAdmitted`]. Everything else is this node being unable to
4415    /// ask right now, which is transient, so the peer is told [`StartErrorReason::Busy`] and may
4416    /// retry.
4417    ///
4418    /// A node with no session server has nobody to ask, and admits on its own terms.
4419    async fn request_admission(
4420        &self,
4421        session_id: SessionId,
4422        req: &StartInitiation<SessionTarget, HoprSessionCapabilities>,
4423        offered: Option<PixParams>,
4424    ) -> Result<SessionAdmissionDecision, StartErrorReason> {
4425        let Some(admission_tx) = self.admission_tx.get() else {
4426            return Ok(SessionAdmissionDecision::default());
4427        };
4428
4429        let (reply_tx, reply_rx) = oneshot::channel();
4430        // The capability bits travel as the peer sent them; the offer travels decoded, since the
4431        // wire encoding is this crate's to read and the server has no business unpacking it.
4432        let request = SessionAdmissionRequest::new(session_id, req.target.clone(), req.capabilities.0.bits());
4433        let request = match offered {
4434            Some(params) => request.with_offer(OfferedIncentivization::new(
4435                params.polys_per_ssa(),
4436                params.shares_per_poly(),
4437                params.surplus_shares(),
4438                pix_params_to_quota(&params),
4439            )),
4440            None => request,
4441        };
4442
4443        // `try_send` rather than an awaited send: waiting for room in the queue would hold this
4444        // initiation past the peer's own timeout, and the queue being full is exactly the condition
4445        // the bound exists to report.
4446        if let Err(error) = admission_tx.lock().try_send((request, reply_tx)) {
4447            warn!(
4448                %session_id,
4449                %error,
4450                "cannot ask the session server to admit this session; refusing it as busy"
4451            );
4452            return Err(StartErrorReason::Busy);
4453        }
4454
4455        match reply_rx
4456            .timeout(futures_time::time::Duration::from(self.cfg.session_admission_timeout))
4457            .await
4458        {
4459            Ok(Ok(Ok(decision))) => Ok(decision),
4460            Ok(Ok(Err(error))) => {
4461                debug!(%session_id, %error, "the session server refused this session's target");
4462                Err(StartErrorReason::TargetNotAdmitted)
4463            }
4464            // The server dropped the reply without answering, which is a bug in it rather than a
4465            // decision, so it is reported as transient like the timeout it resembles.
4466            Ok(Err(_)) => {
4467                error!(
4468                    %session_id,
4469                    "the session server dropped an admission request without answering it"
4470                );
4471                Err(StartErrorReason::Busy)
4472            }
4473            Err(_) => {
4474                warn!(
4475                    %session_id,
4476                    timeout = ?self.cfg.session_admission_timeout,
4477                    "the session server did not decide in time; refusing the session as busy"
4478                );
4479                Err(StartErrorReason::Busy)
4480            }
4481        }
4482    }
4483
4484    /// Checks the offer against the terms this Session was admitted on.
4485    ///
4486    /// Judged against `policy` — the node's configuration as narrowed by the session server's
4487    /// decision for this Session — rather than against the node's configuration directly, which is
4488    /// what makes the accepted quota a property of the target as well as of the node.
4489    ///
4490    /// `offered` is what [`decode_pix_offer`] read, so this is only the
4491    /// policy question; the wire has already been judged.
4492    ///
4493    /// Returns the validated parameters and selected SSA batch size, or `None` if the offer cannot
4494    /// satisfy that policy.
4495    fn check_pix_params(
4496        &self,
4497        challenge: StartChallenge,
4498        offered: Option<PixParams>,
4499        policy: &EffectivePixAdmission,
4500    ) -> Option<(PixParams, usize)> {
4501        if let Some(params) = offered {
4502            let quota_per_ssa = pix_params_to_quota(&params);
4503            let ssas_per_request = policy.ssa_batch_size_for_quota(quota_per_ssa);
4504            let accepted_quota = ssas_per_request.and_then(|batch_size| quota_per_ssa.checked_mul(batch_size as u64));
4505            debug!(
4506                challenge,
4507                %params,
4508                acceptable_range = ?policy.quota_range,
4509                node_range = ?self.cfg.pix_config.quota_range,
4510                dynamic_batches = policy.allow_dynamic_ssa_batches,
4511                configured_max_ssas_per_request = policy.ssas_per_request,
4512                selected_ssas_per_request = ?ssas_per_request,
4513                offered_quota_mb_per_ssa = quota_per_ssa as f64 / (1024.0 * 1024.0),
4514                accepted_quota_mb = ?accepted_quota.map(|quota| quota as f64 / (1024.0 * 1024.0)),
4515                "client offered PIX SSA quota"
4516            );
4517
4518            // A window narrowed to nothing means the session server's rule and this node's envelope
4519            // do not overlap, which is a misconfiguration rather than a peer offering badly: no
4520            // offer whatsoever could have been accepted, so it is worth saying out loud once per
4521            // Session rather than leaving it to look like a rejected client.
4522            if ssas_per_request.is_none() && policy.quota_range.is_empty() {
4523                warn!(
4524                    challenge,
4525                    admitted_range = ?policy.quota_range,
4526                    node_range = ?self.cfg.pix_config.quota_range,
4527                    "the admitted quota range for this target is empty, so no offer can be accepted"
4528                );
4529            }
4530
4531            ssas_per_request.map(|batch_size| (params, batch_size))
4532        } else if policy.enforce_pix {
4533            // Client didn't offer PIX, but PIX is enforced
4534            None
4535        } else {
4536            // Client didn't offer PIX, and PIX is not enforced, so set default values
4537            // which are not going to be used.
4538            Some((DEFAULT_PIX_PARAMS, 1))
4539        }
4540    }
4541
4542    #[tracing::instrument(level = "debug", skip(self, session_req))]
4543    async fn handle_incoming_session_initiation(
4544        &self,
4545        pseudonym: HoprPseudonym,
4546        session_req: StartInitiation<SessionTarget, HoprSessionCapabilities>,
4547    ) -> errors::Result<()> {
4548        trace!(challenge = session_req.challenge, "received session initiation request");
4549
4550        debug!("got new session request, searching for a free session slot");
4551
4552        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
4553
4554        // Reply routing uses SURBs only with the pseudonym of this Session's ID
4555        let reply_routing = DestinationRouting::Return(pseudonym.into());
4556
4557        // Reject UsePIX if this node is not configured with a PixToolbox
4558        // (e.g. relay nodes that do not participate in PIX processing).
4559        if self.pix_toolbox.get().is_none() && session_req.capabilities.0.contains(Capability::UsePIX) {
4560            error!(
4561                challenge = session_req.challenge,
4562                "client offered PIX but this node has no PIX support installed"
4563            );
4564            let data = HoprStartProtocol::SessionError(StartErrorType {
4565                identifier: ErrorIdentifier::Challenge(session_req.challenge),
4566                reason: StartErrorReason::UnacceptablePixParams,
4567            });
4568            // Counted before the send, not after: the refusal has already happened, and
4569            // `send_via_msg_sender` can fail to encode, fail to send, or time out. Counting
4570            // afterwards would drop exactly the refusals that a struggling node makes most.
4571            PixAdmissionRejection::NoPixSupport.record_if_pix(session_req.capabilities.0);
4572            send_via_msg_sender(
4573                &mut msg_sender,
4574                reply_routing,
4575                data,
4576                "session error due to missing PIX support",
4577            )
4578            .await?;
4579            return Ok(());
4580        }
4581
4582        // Ask whoever runs the session server on what terms this target may be served, before
4583        // anything is allocated for the Session. This is the only place the target can influence
4584        // admission: the transport cannot read a sealed target, and by the time the server sees the
4585        // established Session the terms have already been fixed.
4586        // Read the offer before anyone is asked about it: an offer this node cannot read, or whose
4587        // curve it does not share, is refused without a round trip to the session server, and the
4588        // server is told what was offered rather than having to unpack the wire itself.
4589        let offered = match decode_pix_offer(&session_req) {
4590            Ok(offered) => offered,
4591            Err(reason) => {
4592                error!(
4593                    challenge = session_req.challenge,
4594                    "client offered unacceptable PIX parameters"
4595                );
4596                let data = HoprStartProtocol::SessionError(StartErrorType {
4597                    identifier: ErrorIdentifier::Challenge(session_req.challenge),
4598                    reason,
4599                });
4600                PixAdmissionRejection::UnacceptableParams.record_if_pix(session_req.capabilities.0);
4601                send_via_msg_sender(
4602                    &mut msg_sender,
4603                    reply_routing,
4604                    data,
4605                    "session error message due to unacceptable PIX parameters",
4606                )
4607                .await?;
4608
4609                // Left after the send, unlike the line above: this one counts errors *sent*, so a
4610                // send that never happened is correctly not one of them.
4611                #[cfg(all(feature = "telemetry", not(test)))]
4612                METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4613                return Ok(());
4614            }
4615        };
4616
4617        let session_id: SessionId = pseudonym;
4618        let admission = match self.request_admission(session_id, &session_req, offered).await {
4619            Ok(decision) => decision,
4620            Err(reason) => {
4621                let data = HoprStartProtocol::SessionError(StartErrorType {
4622                    identifier: ErrorIdentifier::Challenge(session_req.challenge),
4623                    reason,
4624                });
4625                // `request_admission` distinguishes these two on purpose — a refused target will not
4626                // be served however the request is phrased, while `Busy` is this node being unable
4627                // to ask right now and is worth retrying — so the aggregate keeps them apart too.
4628                match reason {
4629                    StartErrorReason::TargetNotAdmitted => PixAdmissionRejection::TargetPolicy,
4630                    _ => PixAdmissionRejection::Busy,
4631                }
4632                .record_if_pix(session_req.capabilities.0);
4633                send_via_msg_sender(
4634                    &mut msg_sender,
4635                    reply_routing,
4636                    data,
4637                    "session error due to a refused admission",
4638                )
4639                .await?;
4640
4641                #[cfg(all(feature = "telemetry", not(test)))]
4642                METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4643                return Ok(());
4644            }
4645        };
4646        let pix_policy = self.cfg.pix_config.effective_for(&admission);
4647
4648        // Verify the offer against the terms this Session was admitted on.
4649        let Some((client_params, ssas_per_request)) =
4650            self.check_pix_params(session_req.challenge, offered, &pix_policy)
4651        else {
4652            error!(
4653                challenge = session_req.challenge,
4654                "client offered unacceptable PIX parameters"
4655            );
4656
4657            // Notify the sender that the session could not be established
4658            let reason = StartErrorReason::UnacceptablePixParams;
4659            let data = HoprStartProtocol::SessionError(StartErrorType {
4660                identifier: ErrorIdentifier::Challenge(session_req.challenge),
4661                reason,
4662            });
4663            PixAdmissionRejection::UnacceptableParams.record_if_pix(session_req.capabilities.0);
4664            send_via_msg_sender(
4665                &mut msg_sender,
4666                reply_routing,
4667                data,
4668                "session error message due to unacceptable PIX parameters",
4669            )
4670            .await?;
4671
4672            #[cfg(all(feature = "telemetry", not(test)))]
4673            METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4674            return Ok(());
4675        };
4676
4677        info!(params = %client_params, ssas_per_request, "client offered acceptable PIX parameters");
4678
4679        // Charge this Session's reconstructor state against the node-wide budget before anything is
4680        // allocated for it. Only a PIX Session holds cycle state, so only a PIX Session is charged —
4681        // `check_pix_params` hands back nominal parameters for a peer that offered none, and
4682        // reserving on those would bill Sessions that never reconstruct anything.
4683        //
4684        // Refused here rather than at the successor request, which is the other place the ceiling
4685        // could be applied: by then the Entry has funded a cycle and refusing costs it that deposit,
4686        // whereas a Session refused now is one the peer can retry elsewhere at no charge.
4687        let cycle_budget = if session_req.capabilities.0.contains(Capability::UsePIX) {
4688            let Some(reservation) = self.reserve_cycle_budget(&client_params, ssas_per_request) else {
4689                warn!(
4690                    challenge = session_req.challenge,
4691                    requested = cycle_budget_for(&client_params, ssas_per_request),
4692                    outstanding = self.live_cycle_bytes.load(Ordering::Relaxed),
4693                    ceiling = self.cfg.pix_config.max_live_cycle_bytes,
4694                    "refusing a PIX session: the node's live reconstructor-cycle budget is exhausted"
4695                );
4696
4697                let reason = StartErrorReason::NoSlotsAvailable;
4698                let data = HoprStartProtocol::SessionError(StartErrorType {
4699                    identifier: ErrorIdentifier::Challenge(session_req.challenge),
4700                    reason,
4701                });
4702                // The refusal this whole aggregate exists to separate from the one below. Both are
4703                // `NoSlotsAvailable` on the wire, but this one says the node is healthy and full of
4704                // reconstructor state, and the other says it is at its Session limit.
4705                PixAdmissionRejection::LiveCycleCapacity.record_if_pix(session_req.capabilities.0);
4706                send_via_msg_sender(
4707                    &mut msg_sender,
4708                    reply_routing,
4709                    data,
4710                    "session error message due to an exhausted live-cycle budget",
4711                )
4712                .await?;
4713
4714                #[cfg(all(feature = "telemetry", not(test)))]
4715                METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4716                return Ok(());
4717            };
4718            Some(reservation)
4719        } else {
4720            None
4721        };
4722
4723        let (new_session_notifier, close_session_notifier) = self
4724            .session_notifiers
4725            .get()
4726            .cloned()
4727            .ok_or(SessionManagerError::NotStarted)?;
4728
4729        // Use constant application tag for all sessions
4730        self.sessions.run_pending_tasks();
4731
4732        let (session_tx, session_rx) =
4733            crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
4734        let (session_rx, session_rx_ah) = hopr_utils::runtime::DropAbortable::new(session_rx.into_stream());
4735
4736        let slot = SessionSlot {
4737            session_tx,
4738            routing_opts: reply_routing.clone(),
4739            abort_handles: Default::default(),
4740            surb_mgmt: Default::default(),
4741            surb_estimator: Default::default(),
4742            current_ssa_state: Default::default(),
4743            pix_supervisor: Default::default(),
4744            pix_egress_gate: Default::default(),
4745            pix_fill: Default::default(),
4746            returned_packets: Default::default(),
4747            cycle_budget,
4748        };
4749        slot.abort_handles.lock().insert(SessionHandles::Ingress, session_rx_ah);
4750
4751        // Insert the slot and get a guard. Any failure from here on rolls the slot
4752        // back, otherwise it would block this pseudonym until idle eviction. The atomic
4753        // insert (inside the helper) also prevents a TOCTOU race, so only one concurrent
4754        // request can claim the slot for a given pseudonym.
4755        let Some(mut slot_guard) = self.allocate_session_slot(session_id, slot.clone()) else {
4756            // No slots available for this pseudonym
4757            error!("no slots available for this pseudonym");
4758            let reason = StartErrorReason::NoSlotsAvailable;
4759            let data = HoprStartProtocol::SessionError(StartErrorType {
4760                identifier: ErrorIdentifier::Challenge(session_req.challenge),
4761                reason,
4762            });
4763
4764            PixAdmissionRejection::NoSessionSlot.record_if_pix(session_req.capabilities.0);
4765            send_via_msg_sender(
4766                &mut msg_sender,
4767                reply_routing.clone(),
4768                data,
4769                "session error message due to lack of slots",
4770            )
4771            .await?;
4772
4773            #[cfg(all(feature = "telemetry", not(test)))]
4774            METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4775
4776            return Ok(());
4777        };
4778
4779        debug!(?session_req, "assigned a new session");
4780
4781        // Stand the supervisor up before the `HoprSession` is constructed, so the egress adapters
4782        // below see a populated gate. Installing it afterwards would leave a window in which packets
4783        // are served ungated — small, but exactly the window an unfunded Entry would aim for.
4784        //
4785        // Its first `RequestSsa` action is deliberately *not* carried out here: the action driver
4786        // spawned after publication does it, which is what keeps `SessionEstablished` ahead of
4787        // `SsaRequest` on the wire.
4788        let pix: Option<ActionRx> = if self.pix_toolbox.get().is_some()
4789            && session_req.capabilities.0.contains(Capability::UsePIX)
4790        {
4791            // We use the same dimensions the client offered.
4792            slot.current_ssa_state
4793                .set(SessionSsaState::new(client_params))
4794                .map_err(|_| SessionManagerError::other(anyhow::anyhow!("session pix state must be uninitialized")))?;
4795
4796            let supervisor_config = SupervisorConfig {
4797                ssas_per_request,
4798                ..self.cfg.pix_config.supervisor_config()
4799            };
4800            let (handle, action_rx) =
4801                spawn_supervisor_worker(supervisor_config, client_params, session_id, std::time::Instant::now());
4802
4803            let _ = slot.pix_egress_gate.set(handle.gate.clone());
4804            let _ = slot.pix_supervisor.set(handle);
4805            Some(action_rx)
4806        } else {
4807            None
4808        };
4809
4810        // The SURB buffer target the Entry asked for, resolved once for both branches below.
4811        //
4812        // The Session request carries a "hint" as additional data telling what the Session initiator
4813        // has configured as its target buffer size in the Balancer. The lower 32 bits of the options
4814        // word contain the SURB target; its upper 32 bits carry PIX parameters and must be masked out.
4815        //
4816        // Resolved even on the `NoRateControl` branch, which runs no balancer and never announces a
4817        // target of its own: the Exit's keep-alive stream derives its fill SURB reserve from this
4818        // figure, and on that branch the fallback below — the same one the balancer would have used —
4819        // is the only estimate of the buffer there is.
4820        let announced_surb_target = {
4821            let surb_target = initiation_options(session_req.additional_data) as u32;
4822            if surb_target > 0 {
4823                (surb_target as u64).min(self.cfg.maximum_surb_buffer_size as u64)
4824            } else {
4825                self.cfg.initial_return_session_egress_rate as u64
4826                    * self
4827                        .cfg
4828                        .minimum_surb_buffer_duration
4829                        .max(MIN_SURB_BUFFER_DURATION)
4830                        .as_secs()
4831            }
4832        };
4833
4834        let closure_notifier = Box::new(move |session_id: SessionId, reason: ClosureReason| {
4835            if let Err(error) = close_session_notifier.try_send((session_id, reason)) {
4836                error!(%session_id, %error, %reason, "failed to notify session closure");
4837            }
4838        });
4839
4840        let session = if !session_req.capabilities.0.contains(Capability::NoRateControl) {
4841            // Because of SURB scarcity, control the egress rate of incoming sessions
4842            let egress_rate_control =
4843                RateController::new(self.cfg.initial_return_session_egress_rate, Duration::from_secs(1));
4844
4845            let target_surb_buffer_size = announced_surb_target;
4846
4847            let surb_estimator_tx = slot.surb_estimator.clone();
4848            let surb_estimator_rx = slot.surb_estimator.clone();
4849            // Resolved once, here, rather than per packet: the gate was installed before this point,
4850            // so the egress path never has to look inside the `OnceLock` again.
4851            let egress_gate = slot.pix_egress_gate.get().cloned();
4852            let session = HoprSession::new(
4853                session_id,
4854                reply_routing.clone(),
4855                session_config(&self.cfg, session_req.capabilities.into()),
4856                (
4857                    // Sent packets = SURB consumption estimate
4858                    msg_sender
4859                        .clone()
4860                        .sink_map_err(std::io::Error::other)
4861                        .with(move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
4862                            // Each outgoing packet consumes one SURB
4863                            surb_estimator_tx
4864                                .consumed
4865                                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4866                            #[cfg(feature = "telemetry")]
4867                            telemetry::record_session_surb_consumed(&session_id, 1);
4868                            acquire_egress_permit(egress_gate.clone(), routing, data)
4869                        })
4870                        .rate_limit_with_controller(&egress_rate_control)
4871                        .buffer((2 * target_surb_buffer_size) as usize),
4872                    // Received packets = SURB retrieval estimate
4873                    session_rx.inspect(move |data| {
4874                        // SURBs saved into the store, and older ones the full store evicted to make room.
4875                        let produced = data.num_surbs_with_msg() as u64;
4876                        let evicted = data.packet_info.num_evicted_surbs as u64;
4877                        account_incoming_surbs(&surb_estimator_rx, produced, evicted);
4878                        #[cfg(feature = "telemetry")]
4879                        telemetry::record_session_surb_produced(&session_id, produced);
4880                    }),
4881                ),
4882                Some(closure_notifier),
4883            )?;
4884
4885            // The SURB balancer will start intervening by rate-limiting the
4886            // egress of the Session, once the estimated number of SURBs drops below
4887            // the target defined here. Otherwise, the maximum egress is allowed.
4888            let balancer_config = SurbBalancerConfig {
4889                target_surb_buffer_size,
4890                // At maximum egress, the SURB buffer drains in `minimum_surb_buffer_duration` seconds
4891                max_surbs_per_sec: target_surb_buffer_size / self.cfg.minimum_surb_buffer_duration.as_secs(),
4892                // No SURB decay at the Exit, since we know almost exactly how many SURBs
4893                // were received
4894                surb_decay: None,
4895                sustain_on_return_path_loss: false,
4896            };
4897
4898            slot.surb_mgmt.update(&balancer_config);
4899            slot.surb_mgmt
4900                .set_counterparty_buffer_capacity(self.cfg.maximum_surb_buffer_size as u64);
4901
4902            // Spawn the SURB balancer only once we know we have registered the
4903            // abort handle with the pre-allocated Session slot
4904            debug!(%session_id, ?balancer_config ,"spawning exit SURB balancer");
4905            let balancer = SurbBalancer::new(
4906                session_id,
4907                SimpleBalancerController::default(),
4908                slot.surb_estimator.clone(),
4909                SurbControllerWithCorrection(egress_rate_control, 1), // 1 SURB per egress packet
4910                slot.surb_mgmt.clone(),
4911            );
4912
4913            // Assign the SURB balancer and abort handles to the already allocated Session slot
4914            let (_, balancer_abort_handle) = balancer.start_control_loop(self.cfg.balancer_sampling_interval);
4915            slot.abort_handles
4916                .lock()
4917                .insert(SessionHandles::Balancer, balancer_abort_handle);
4918
4919            // Reports the SURB buffer level towards the Entry, and — on a PIX Session — carries the
4920            // Exit's own fill.
4921            self.spawn_exit_keep_alive(
4922                session_id,
4923                &slot,
4924                msg_sender.clone(),
4925                pix.is_some(),
4926                announced_surb_target,
4927            );
4928
4929            session
4930        } else {
4931            // `NoRateControl`: no SURB balancer, but the PIX gate still applies. A Session that
4932            // opts out of rate control is exactly the one that could drain the most service before
4933            // funding, so leaving this path ungated would make the predeposit budget optional.
4934            let egress_gate = slot.pix_egress_gate.get().cloned();
4935            let surb_estimator_tx = slot.surb_estimator.clone();
4936            let surb_estimator_rx = slot.surb_estimator.clone();
4937            let session = HoprSession::new(
4938                session_id,
4939                reply_routing.clone(),
4940                session_config(&self.cfg, session_req.capabilities.into()),
4941                (
4942                    msg_sender.clone().sink_map_err(std::io::Error::other).with(
4943                        move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
4944                            // Counted here too, though nothing on this branch balances anything. The
4945                            // estimate is what the fill reserve is measured against, and a reserve
4946                            // measured against a counter that only ever counts in one direction is a
4947                            // reserve that either never binds or never releases. `NoRateControl` is
4948                            // also the branch that can drain SURBs fastest, so it is the one where
4949                            // that matters most.
4950                            surb_estimator_tx
4951                                .consumed
4952                                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4953                            #[cfg(feature = "telemetry")]
4954                            telemetry::record_session_surb_consumed(&session_id, 1);
4955                            acquire_egress_permit(egress_gate.clone(), routing, data)
4956                        },
4957                    ),
4958                    session_rx.inspect(move |data| {
4959                        // SURBs saved into the store, and older ones the full store evicted to make room.
4960                        let produced = data.num_surbs_with_msg() as u64;
4961                        let evicted = data.packet_info.num_evicted_surbs as u64;
4962                        account_incoming_surbs(&surb_estimator_rx, produced, evicted);
4963                        #[cfg(feature = "telemetry")]
4964                        telemetry::record_session_surb_produced(&session_id, produced);
4965                    }),
4966                ),
4967                Some(closure_notifier),
4968            )?;
4969
4970            self.spawn_exit_keep_alive(
4971                session_id,
4972                &slot,
4973                msg_sender.clone(),
4974                pix.is_some(),
4975                announced_surb_target,
4976            );
4977
4978            session
4979        };
4980
4981        // Extract useful information about the session from the Start protocol message
4982        let incoming_session = IncomingSession {
4983            id: session_id,
4984            session,
4985            target: session_req.target,
4986        };
4987
4988        // Notify that a new incoming session has been created. Lock the sink and send
4989        // directly into it, so no extra forwarding task between channels is needed.
4990        match async {
4991            let mut guard = new_session_notifier.lock().await;
4992            guard.send(incoming_session).await
4993        }
4994        .timeout(futures_time::time::Duration::from(EXTERNAL_SEND_TIMEOUT))
4995        .await
4996        {
4997            Err(_) => {
4998                error!(%session_id, "timeout to notify about new incoming session");
4999                return Err(TransportSessionError::Timeout);
5000            }
5001            Ok(Err(error)) => {
5002                error!(%session_id, %error, "failed to notify about new incoming session");
5003                return Err(SessionManagerError::other(error).into());
5004            }
5005            _ => {}
5006        };
5007
5008        trace!(?session_id, "session notification sent");
5009
5010        // Notify the sender that the session has been established.
5011        // Set our peer ID in the session ID sent back to them.
5012        let data = HoprStartProtocol::SessionEstablished(StartEstablished {
5013            orig_challenge: session_req.challenge,
5014            session_id,
5015        });
5016
5017        send_via_msg_sender(
5018            &mut msg_sender,
5019            reply_routing.clone(),
5020            data,
5021            "session establishment message",
5022        )
5023        .await?;
5024
5025        #[cfg(feature = "telemetry")]
5026        initialize_session_telemetry(
5027            session_id,
5028            &self.cfg,
5029            session_req.capabilities.0,
5030            Some(&slot.surb_estimator),
5031            Some(&slot.surb_mgmt),
5032        );
5033
5034        // The session is published, so the supervisor's actions can now be carried out. The first of
5035        // them is the initial `RequestSsa`, which is why this runs after `SessionEstablished` has
5036        // gone out: the Entry must see the two in that order.
5037        if let Some(action_rx) = pix {
5038            let driver = self.spawn_pix_action_driver(session_id, &slot, action_rx, reply_routing.clone());
5039            slot.abort_handles
5040                .lock()
5041                .insert(SessionHandles::PixActionDriver, driver);
5042        }
5043
5044        #[cfg(all(feature = "telemetry", not(test)))]
5045        METRIC_NUM_ESTABLISHED_SESSIONS.increment();
5046
5047        slot_guard.commit();
5048
5049        Ok(())
5050    }
5051
5052    #[tracing::instrument(level = "debug", skip(self, est))]
5053    async fn handle_session_established(&self, est: StartEstablished<SessionId>) -> errors::Result<()> {
5054        debug!(
5055            session_id = ?est.session_id,
5056            "received session establishment confirmation"
5057        );
5058        let challenge = est.orig_challenge;
5059        let session_id = est.session_id;
5060
5061        if let Some(tx_est) = self.session_initiations.remove(&challenge) {
5062            if let Err(error) = tx_est.try_send(Ok(est)) {
5063                error!(%challenge, %session_id, %error, "failed to send session establishment confirmation");
5064                return Err(SessionManagerError::other(error).into());
5065            }
5066            debug!(?session_id, challenge, "session establishment complete");
5067        } else {
5068            error!(%session_id, challenge, "unknown session establishment attempt or expired");
5069        }
5070        Ok(())
5071    }
5072
5073    #[tracing::instrument(level = "debug", skip(self))]
5074    async fn handle_session_error(&self, error_type: StartErrorType<SessionId>) -> errors::Result<()> {
5075        let reason = error_type.reason;
5076        match error_type.identifier {
5077            ErrorIdentifier::Challenge(challenge) => {
5078                trace!(%challenge, error = ?error_type.reason, "session initiation error received");
5079                if let Some(tx_est) = self.session_initiations.remove(&challenge) {
5080                    if let Err(error) = tx_est.try_send(Err(error_type)) {
5081                        error!(%error, "could not send session error message");
5082                        return Err(SessionManagerError::other(error).into());
5083                    }
5084                    error!(%challenge, "session establishment error received");
5085                } else {
5086                    error!(
5087                        %challenge,
5088                        "session establishment attempt expired before error could be delivered"
5089                    );
5090                }
5091            }
5092            ErrorIdentifier::SessionId(session_id) => {
5093                error!(
5094                    %session_id, %reason,
5095                    "received post-establishment session error — closing session"
5096                );
5097                // Best-effort close; the session may have already been removed.
5098                self.close_session(&session_id);
5099            }
5100        }
5101
5102        #[cfg(all(feature = "telemetry", not(test)))]
5103        METRIC_RECEIVED_SESSION_ERRS.increment(&[&reason.to_string()]);
5104
5105        Ok(())
5106    }
5107
5108    #[tracing::instrument(level = "trace", skip(self, msg))]
5109    async fn handle_keep_alive(&self, msg: KeepAliveMessage<SessionId>) -> errors::Result<()> {
5110        let session_id = msg.session_id;
5111        if let Some(session_slot) = self.sessions.get(&session_id) {
5112            trace!(?session_id, "received keep-alive message");
5113            match &session_slot.routing_opts {
5114                // Session is outgoing - keep-alive was received from the Exit
5115                DestinationRouting::Forward { .. } => {
5116                    if msg.flags.contains(KeepAliveFlag::BalancerState) && !session_slot.surb_mgmt.is_disabled() {
5117                        // Record the reported level and let it infer the counterparty's real store
5118                        // capacity from a plateau. Every report is fed in, including one that repeats
5119                        // the last value -- a level that stops rising while we are still minting is
5120                        // exactly the plateau signal, so this must not dedup equal reports.
5121                        let changed = session_slot.surb_mgmt.buffer_level() != msg.additional_data;
5122                        session_slot.surb_mgmt.observe_reported_level(msg.additional_data);
5123                        if changed {
5124                            debug!(%session_id, surb_level = msg.additional_data, "keep-alive updated SURB buffer size from the Exit");
5125                        }
5126                    }
5127
5128                    // Increase the number of consumed SURBs in the estimator
5129                    session_slot
5130                        .surb_estimator
5131                        .consumed
5132                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5133                    #[cfg(feature = "telemetry")]
5134                    telemetry::record_session_surb_consumed(&session_id, 1);
5135                }
5136                // Session is incoming - keep-alive was received from the Entry
5137                DestinationRouting::Return(_) => {
5138                    // Allow updating SURB balancer target based on the received Keep-Alive message
5139                    if msg.flags.contains(KeepAliveFlag::BalancerTarget)
5140                        && msg.additional_data > 0
5141                        && !session_slot.surb_mgmt.is_disabled()
5142                        && session_slot.surb_mgmt.controller_bounds().target() != msg.additional_data
5143                    {
5144                        // Update the target buffer size as sent to us from the Entry
5145                        session_slot
5146                            .surb_mgmt
5147                            .target_surb_buffer_size
5148                            .store(msg.additional_data, std::sync::atomic::Ordering::Relaxed);
5149                        // Update maximum SURBs per second based on the new target
5150                        session_slot.surb_mgmt.max_surbs_per_sec.store(
5151                            msg.additional_data / self.cfg.minimum_surb_buffer_duration.as_secs(),
5152                            std::sync::atomic::Ordering::Relaxed,
5153                        );
5154                        debug!(%session_id, target_surb_buffer_size = msg.additional_data, "keep-alive updated SURB balancer target buffer size from the Entry");
5155                    }
5156
5157                    // Increase the number of received SURBs in the estimator.
5158                    let produced = msg.max_surbs()? as u64;
5159                    session_slot
5160                        .surb_estimator
5161                        .produced
5162                        .fetch_add(produced, std::sync::atomic::Ordering::Relaxed);
5163                    #[cfg(feature = "telemetry")]
5164                    telemetry::record_session_surb_produced(&session_id, produced);
5165                }
5166            }
5167        } else {
5168            debug!(%session_id, "received keep-alive request for an unknown session");
5169        }
5170
5171        Ok(())
5172    }
5173
5174    /// Handled by the Exit, when Entry replies with PIX commitment
5175    #[tracing::instrument(level = "debug", skip(self, msg))]
5176    async fn handle_ssa_commit(
5177        &self,
5178        pseudonym: HoprPseudonym,
5179        msg: SsaClientCommitmentMessage<SessionId, HoprPixGroupElement, HoprPixCommitmentProof>,
5180    ) -> errors::Result<()> {
5181        let Some(pix_toolbox) = self.pix_toolbox.get().cloned() else {
5182            return Err(SessionManagerError::UnsupportedMessage.into());
5183        };
5184
5185        let session_id = msg.session_id;
5186
5187        if pseudonym != session_id {
5188            error!(%pseudonym, %msg.session_id, "received SSA client commitment for a different session");
5189            return Err(SessionManagerError::NonExistingSession.into());
5190        }
5191
5192        let Some(session_slot) = self.sessions.get(&session_id) else {
5193            return Err(SessionManagerError::NonExistingSession.into());
5194        };
5195
5196        // See if we haven't received an SSA commitment for a Session that we did not register as PIX-capable
5197        let Some(ssa_state) = session_slot.current_ssa_state.get() else {
5198            return Err(SessionManagerError::Other(anyhow::anyhow!("no SSA state for session {session_id}")).into());
5199        };
5200        let quota_per_ssa = ssa_state.quota_per_ssa();
5201
5202        let ssa_id = SsaId::new(pseudonym, msg.ssa_index);
5203
5204        // Decode the accompanying proof of knowledge, if the message carries one. A malformed proof
5205        // is rejected here rather than being passed on as absent, so that it cannot be mistaken for
5206        // a peer that simply did not send one.
5207        let commitment_proof = msg
5208            .commitment_proof
5209            .map(|proof| proof.try_into_pix_proof())
5210            .transpose()
5211            .map_err(SessionManagerError::other)?;
5212
5213        // Insert the newly received coefficients into the SSA Reconstructor
5214        let pix_toolbox_clone = pix_toolbox.clone();
5215        let ssa_client_commitment_state = hopr_utils::parallelize::cpu::spawn_blocking(
5216            move || {
5217                pix_toolbox_clone
5218                    .share_processor
5219                    .insert_coefficient_commitments(
5220                        ssa_id,
5221                        msg.coefficient_index,
5222                        commitment_proof,
5223                        msg.coefficient_commitments.into_iter().map(|(k, v)| (k, v.0)),
5224                    )
5225                    .map_err(SessionManagerError::PixError)
5226            },
5227            "ssa commitment reconstructor",
5228        )
5229        .await
5230        .map_err(|_| {
5231            SessionManagerError::Other(anyhow::anyhow!(
5232                "failed to insert SSA coefficients into the SSA reconstructor"
5233            ))
5234        })??;
5235
5236        // A verifiable commitment is what starts the deposit clock, so tell the supervisor before
5237        // the observer below can report anything against it.
5238        //
5239        // An *incomplete* one is reported too, and per message: that is what arms the supervisor's
5240        // re-request timer and keeps pushing it out while the burst is still arriving, so it fires
5241        // only once delivery has actually stalled. It is also the only evidence that the Entry
5242        // answered at all, which is what separates a cycle worth re-asking for from one where
5243        // nothing was ever delivered and there is no scope to name.
5244        if let Some(supervisor) = session_slot.pix_supervisor.get() {
5245            let event = if ssa_client_commitment_state.is_verifiable {
5246                SessionPixEvent::CommitmentVerified(ssa_id)
5247            } else {
5248                SessionPixEvent::CommitmentProgress(ssa_id)
5249            };
5250            if supervisor.send_event(event).await.is_err() {
5251                error!(%session_id, %ssa_id, "pix supervisor is no longer accepting events");
5252            }
5253        }
5254
5255        if ssa_client_commitment_state.deposit_address_first_encountered
5256            && let Some(deposit_address) = ssa_client_commitment_state.ssa_deposit_address
5257        {
5258            // Inside the guard on purpose: every other `SsaCommit` of a cycle takes the other branch,
5259            // so allocating this before the `if` built and dropped a channel per message.
5260            let (deposit_done_tx, deposit_done_rx) =
5261                futures::channel::mpsc::channel::<(PixAddressId, hopr_api::HoprBalance)>(10);
5262            // Report deposits for as long as they arrive rather than waiting for the first one and
5263            // stopping: the pool may report more than once for a cycle, and a first message carrying
5264            // a zero balance asserts nothing, so the one that funds the cycle need not be the first.
5265            // The supervisor owns the deadline, so the observer carries none — a timeout here would
5266            // have been a second authority racing the first.
5267            let supervisor = session_slot.pix_supervisor.get().cloned();
5268            session_slot.abort_handles.lock().insert(
5269                SessionHandles::PixDepositObserver(ssa_id.ssa_index().get()),
5270                hopr_utils::spawn_as_abortable!(async move {
5271                    let Some(supervisor) = supervisor else {
5272                        // No supervisor: a Session that negotiated PIX always has one, so this is
5273                        // only reachable if it died between the check above and here.
5274                        return;
5275                    };
5276                    let mut confirmations = deposit_done_rx.filter(|(evt_id, _)| {
5277                        futures::future::ready(
5278                            evt_id.ssa_index() == ssa_id.ssa_index() && &evt_id.session_id() == ssa_id.pseudonym(),
5279                        )
5280                    });
5281                    while let Some((_, amount)) = confirmations.next().await {
5282                        info!(%session_id, %ssa_id, %amount, "ssa deposit confirmed");
5283                        if supervisor
5284                            .send_event(SessionPixEvent::DepositConfirmed { ssa_id, amount })
5285                            .await
5286                            .is_err()
5287                        {
5288                            return;
5289                        }
5290                    }
5291                    // The upper layer dropped the sender without ever confirming, so no deposit is
5292                    // coming. Saying so is better than letting the supervisor wait out its deadline.
5293                    warn!(%session_id, %ssa_id, "deposit channel closed without confirmation");
5294                    let _ = supervisor
5295                        .send_event(SessionPixEvent::DepositObserverClosed(ssa_id))
5296                        .await;
5297                }),
5298            );
5299
5300            // Notify upstream that deposit is needed
5301            pix_toolbox
5302                .pix_events
5303                .try_send(HoprSessionOutPixEvent::DepositNeeded(
5304                    AgreedSsaQuota {
5305                        ssa_id,
5306                        deposit_address,
5307                        quota_per_ssa,
5308                        // Handed back to the pool that produced it, now that the address it pays for
5309                        // is known. Consumed here: this branch is reached once per cycle.
5310                        deposit_data: ssa_state.take_sent_deposit_data(&session_id, ssa_id.ssa_index()),
5311                    },
5312                    deposit_done_tx,
5313                ))
5314                .map_err(|_| {
5315                    SessionManagerError::other(anyhow::anyhow!("failed to send pix event for needed deposit"))
5316                })?;
5317            info!(%ssa_id, %deposit_address, quota_per_ssa, "retrieved first client SSA commitment and deposit address");
5318        }
5319
5320        Ok(())
5321    }
5322
5323    /// Tells the Exit that its [`SsaServerCommitmentMessage`] was refused, and tears down this half
5324    /// of the Session.
5325    ///
5326    /// Without the notice the refusal is invisible to the Exit, and it has no way to recover from it.
5327    /// Its supervisor registered an Exit commitment per requested index and armed each cycle's
5328    /// commitment deadline the moment the request went out; it will never receive an `SsaCommit`, and
5329    /// nothing can make it re-ask, because a new `RequestSsa` only comes from share-recovery events and
5330    /// no shares exist for a cycle the Entry never committed to. So it serves the Session
5331    /// unincentivized for the whole batch-scaled `max_ssa_delivery_time` and then closes it as
5332    /// `CommitmentTimeout` — a reason that names the clock rather than the refusal, on the one node
5333    /// whose operator can act on it.
5334    ///
5335    /// Closing this half too is what stops the Entry sitting on a Session that can never make PIX
5336    /// progress: a refusal is terminal either way, since the Exit re-derives every request from state
5337    /// that cannot drift within a Session, so a later one would be refused identically. Left alone the
5338    /// slot would survive until the idle timeout.
5339    ///
5340    /// No new capability is handed to an attacker by closing on a refusal: an `SsaRequest` only reaches
5341    /// here Sphinx-authenticated and with `pseudonym == session_id`, so only the Exit can produce one —
5342    /// and the Exit can already close the Session whenever it likes.
5343    async fn refuse_ssa_request(&self, session_id: SessionId, routing: DestinationRouting) {
5344        self.notify_session_error(
5345            session_id,
5346            routing,
5347            StartErrorReason::UnacceptablePixParams,
5348            "session error due to a refused SSA request",
5349        )
5350        .await;
5351
5352        if self.close_session(&session_id) {
5353            error!(%session_id, "closed session after refusing the Exit's SSA request");
5354        }
5355    }
5356
5357    /// Handled by the Entry, when the Exit says it never received parts of a commitment this node
5358    /// already published.
5359    ///
5360    /// Re-sends the named polynomial commitments and nothing else. Emits no
5361    /// [`ReadyToDeposit`](HoprSessionOutPixEvent::ReadyToDeposit): the deposit address of a cycle is
5362    /// fixed when it is first committed, and the caller was told it then, so announcing it again
5363    /// would invite a second deposit against one quota.
5364    ///
5365    /// A failure here is reported to the caller of the Start-message dispatcher and otherwise
5366    /// dropped. It is deliberately not [`refuse_ssa_request`](Self::refuse_ssa_request), which closes
5367    /// the Session: an Exit that asks for a cycle this node cannot serve is asking for something
5368    /// already lost, and its own commitment deadline ends the Session either way — whereas closing on
5369    /// a request that merely raced our own state would throw away a Session that was about to work.
5370    ///
5371    /// Two separate bounds keep this from being a packet-amplification lever, because one request may
5372    /// name a whole cycle's polynomials and the answer to that is the entire commitment burst again.
5373    /// The generator bounds how often it answers for *one* cycle
5374    /// ([`MAX_COMMITMENT_RETRANSMISSIONS`](hopr_protocol_pix::MAX_COMMITMENT_RETRANSMISSIONS)); this
5375    /// function bounds how many cycles one request may set going. Neither stands in for the other —
5376    /// the first bounds repeats, the second bounds breadth.
5377    async fn handle_ssa_recommit(
5378        &self,
5379        pseudonym: HoprPseudonym,
5380        session_slot: &SessionSlot,
5381        msg: SsaServerCommitmentMessage<SessionId, HoprPixGroupElement, HoprPixDepositPayload>,
5382    ) -> errors::Result<()> {
5383        // The wire bounds the *runs* a request carries, not the number of cycles they are spread
5384        // over — one run of 8 bytes can name a whole cycle — so without this a single packet names
5385        // every retained cycle and is answered with a full commitment burst for each. The generator's
5386        // per-cycle attempt cap does not bound that: it bounds repeats of one cycle, not breadth
5387        // across cycles.
5388        //
5389        // The same cap as the new-SSA path, so repairing a batch can never cost more than committing
5390        // to it did, and the Exit's own requests name a single cycle. Dropped rather than refused
5391        // with `refuse_ssa_request`: the peer is already inside its own commitment deadline and
5392        // learns nothing from a refusal it does not learn from that, and replying at all would hand
5393        // back some of the amplification the request was reaching for.
5394        let max_ssas_per_request = self.cfg.max_ssas_per_ssa_request;
5395        if msg.missing.len() > max_ssas_per_request {
5396            return Err(SessionManagerError::Unacceptable(format!(
5397                "Exit asked for retransmissions across {} SSA cycles in a single request, at most \
5398                 {max_ssas_per_request} allowed",
5399                msg.missing.len()
5400            ))
5401            .into());
5402        }
5403
5404        let pix_toolbox = self.pix_toolbox.get().cloned().ok_or(SessionManagerError::NotStarted)?;
5405        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
5406        let session_id = msg.session_id;
5407
5408        for (ssa_index, runs) in msg.missing {
5409            // Rebuilt from the bytes that went out the first time, so the peer's already-filled slots
5410            // see a re-delivery rather than a contradiction.
5411            let pix_toolbox_clone = pix_toolbox.clone();
5412            let commitment = hopr_utils::parallelize::cpu::spawn_blocking(
5413                move || pix_toolbox_clone.share_generator.recommit(&pseudonym, ssa_index, runs),
5414                "client_ssa_recommitment",
5415            )
5416            .await
5417            .map_err(SessionManagerError::other)?
5418            .map_err(SessionManagerError::PixError)?;
5419
5420            let commitment_msgs =
5421                SsaClientCommitmentMessage::new_multiple(session_id, commitment).map_err(SessionManagerError::other)?;
5422            let num_messages = commitment_msgs.len();
5423
5424            for commitment_msg in commitment_msgs {
5425                send_via_msg_sender(
5426                    &mut msg_sender,
5427                    session_slot.routing_opts.clone(),
5428                    HoprStartProtocol::SsaCommit(commitment_msg),
5429                    "re-sent client SSA commitment message",
5430                )
5431                .await?;
5432            }
5433
5434            info!(%session_id, %ssa_index, num_messages, "re-sent the requested SSA commitment parts");
5435        }
5436
5437        Ok(())
5438    }
5439
5440    /// Handled by the Entry, when the Exit sends PIX initiation request
5441    #[tracing::instrument(level = "debug", skip(self, msg))]
5442    async fn handle_ssa_request(
5443        &self,
5444        pseudonym: HoprPseudonym,
5445        msg: SsaServerCommitmentMessage<SessionId, HoprPixGroupElement, HoprPixDepositPayload>,
5446    ) -> errors::Result<()> {
5447        let Some(pix_toolbox) = self.pix_toolbox.get().cloned() else {
5448            return Err(SessionManagerError::UnsupportedMessage.into());
5449        };
5450
5451        if pseudonym != msg.session_id {
5452            error!(%pseudonym, %msg.session_id, "received SSA server commitment for a different session");
5453            return Err(SessionManagerError::NonExistingSession.into());
5454        }
5455
5456        // The SsaRequest can arrive before new_session() or handle_incoming_session_initiation
5457        // has finished allocating the session slot, since both SessionEstablished and SsaRequest
5458        // are sent by the Exit back-to-back and processed concurrently by the Start protocol handler.
5459        // Instead of busy-looping, await the allocation notification for this specific SessionId.
5460        let session_slot = {
5461            use std::collections::hash_map::Entry;
5462            let session_id = msg.session_id;
5463            let waiter_deadline = Instant::now() + Duration::from_secs(30);
5464            loop {
5465                if Instant::now() >= waiter_deadline {
5466                    error!(%session_id, "session slot waiter deadline exceeded");
5467                    return Err(SessionManagerError::NonExistingSession.into());
5468                }
5469                // Optimistic cache check
5470                if let Some(slot) = self.sessions.get(&session_id) {
5471                    break slot;
5472                }
5473
5474                // Register a waiter under the lock, then recheck the cache to avoid the
5475                // TOCTOU race between the initial cache check and waiter registration.
5476                let (tx, rx) = oneshot::channel::<()>();
5477                {
5478                    let mut map = self.slot_allocated.lock().unwrap_or_else(|e| e.into_inner());
5479                    // Recheck while holding the lock: the slot may have been inserted
5480                    // between the optimistic check and now. If so, don't register.
5481                    if self.sessions.get(&session_id).is_some() {
5482                        drop(map);
5483                        continue;
5484                    }
5485                    map.entry(session_id).or_default().push(tx);
5486                }
5487
5488                let timeout = futures_time::time::Duration::from(Duration::from_millis(1000));
5489                match rx.timeout(timeout).await {
5490                    Ok(Ok(())) => {
5491                        // Notified — recheck the cache
5492                        continue;
5493                    }
5494                    _ => {
5495                        // Timeout or the sender was dropped (cancelled). Clean up our
5496                        // waiter entry to avoid unbounded accumulation in the map.
5497                        let mut map = self.slot_allocated.lock().unwrap_or_else(|e| e.into_inner());
5498                        if let Entry::Occupied(mut e) = map.entry(session_id) {
5499                            e.get_mut().retain(|w| !w.is_canceled());
5500                            if e.get().is_empty() {
5501                                e.remove();
5502                            }
5503                        }
5504                        drop(map);
5505
5506                        // Final cache recheck before giving up — the slot might have
5507                        // been inserted while we were cleaning up.
5508                        if let Some(slot) = self.sessions.get(&session_id) {
5509                            break slot;
5510                        }
5511
5512                        error!(%session_id, "session slot not found after awaiting allocation");
5513                        return Err(SessionManagerError::NonExistingSession.into());
5514                    }
5515                }
5516            }
5517        };
5518
5519        debug!(
5520            num_server_commitments = msg.commitments.len(),
5521            "received Exit SSA commitments"
5522        );
5523
5524        // Cap how many SSAs a single request may ask us to commit to.
5525        //
5526        // The wire format alone permits `MAX_SSAS_PER_REQUEST` (27) commitments per message, and
5527        // every accepted entry costs a full `new_ssa_commitment` (hundreds of thousands of EC
5528        // commitments) plus thousands of outbound `SsaCommit` packets, and emits its own
5529        // `ReadyToDeposit` — i.e. its own on-chain deposit. Without a cap, a single packet from a
5530        // misbehaving Exit amplifies into minutes of Entry CPU, a large packet burst, and up to 27
5531        // simultaneous deposits bounded only by the per-deposit allocation limit.
5532        //
5533        // Rejecting the whole message rather than the surplus is deliberate: a truncated batch would
5534        // leave the Exit holding reconstructor cycles for indices it will never receive commitments
5535        // for, which its own kill switch then has to clean up.
5536        let max_ssas_per_request = self.cfg.max_ssas_per_ssa_request;
5537        if msg.commitments.len() > max_ssas_per_request {
5538            let error = SessionManagerError::Unacceptable(format!(
5539                "Exit requested {} SSA commitments in a single request, at most {max_ssas_per_request} allowed",
5540                msg.commitments.len()
5541            ));
5542            self.refuse_ssa_request(msg.session_id, session_slot.routing_opts.clone())
5543                .await;
5544            return Err(error.into());
5545        }
5546
5547        let Some(our_params) = session_slot.current_ssa_state.get().map(|s| s.params) else {
5548            return Err(
5549                SessionManagerError::Other(anyhow::anyhow!("no SSA state for session {}", msg.session_id)).into(),
5550            );
5551        };
5552
5553        // The Entry enforces that the Exit's SSA parameters match exactly the ones we offered in the
5554        // Session Initiation message.  Negotiation (accepting an Exit-chosen quota within our
5555        // bounds) is not implemented, so any mismatch is rejected.
5556        //
5557        // The whole quadruple is compared rather than the scalar quota it implies.  Quota equality
5558        // was once argued to be sufficient — the Exit does not pick the dimensions independently, so
5559        // a matching product implied matching `(polys, shares)` from any Exit running unmodified
5560        // code.  The quota now prices three of the four, so it is no longer blind to the surplus,
5561        // but it is still a product: it cannot tell `polys x threshold` from a transposition of the
5562        // two, and those are not interchangeable to the protocol.  Comparing the params is both
5563        // stricter and simpler, and costs nothing now that all four travel together.
5564        //
5565        // The fourth, the curve suite, is why the Entry needs no separate curve check of its own:
5566        // the Exit refused a foreign suite before it sent this message, and an Exit that echoed a
5567        // different one back than the Entry offered fails right here.
5568        //
5569        // Malformed params never reach this comparison as a mismatch: `dimensions()` fails first,
5570        // and that failure takes the same refusal path, so the Exit is told either way rather than
5571        // being left waiting on a dropped packet.
5572        let server_params = match msg.dimensions() {
5573            Ok(params) => params,
5574            Err(error) => {
5575                self.refuse_ssa_request(msg.session_id, session_slot.routing_opts.clone())
5576                    .await;
5577                return Err(
5578                    SessionManagerError::Unacceptable(format!("Exit sent malformed PIX parameters: {error}")).into(),
5579                );
5580            }
5581        };
5582        if our_params != server_params {
5583            let error = SessionManagerError::Unacceptable(format!(
5584                "Exit sent unacceptable PIX parameters {server_params} (ours are {our_params})"
5585            ));
5586            self.refuse_ssa_request(msg.session_id, session_slot.routing_opts.clone())
5587                .await;
5588            return Err(error.into());
5589        }
5590        // A request naming missing commitment parts asks for nothing new — see
5591        // `SsaServerCommitmentMessage` — and none of what follows may run for it.
5592        //
5593        // That placement is the whole safety argument for reusing this message. The successor gate
5594        // below would refuse an index that is *already* being served, which is every index a
5595        // retransmission can name; the served-packet gate prices service against a batch nobody is
5596        // asking for; and `new_ssa_commitment` rejects an index at or below the watermark, which
5597        // again is every index this can name. Above this line is what a retransmission does need:
5598        // the pseudonym check, the session slot, and the parameter comparison.
5599        if !msg.missing.is_empty() {
5600            return self.handle_ssa_recommit(pseudonym, &session_slot, msg).await;
5601        }
5602
5603        let quota_per_ssa = pix_params_to_quota(&our_params);
5604
5605        // Everything from here to the end of the batch is serialised per pseudonym. Start messages run
5606        // under `for_each_concurrent`, so the successor gate below would otherwise be read by several
5607        // requests before any of them advanced it, and every one would pass. See `ssa_request_locks`.
5608        let request_lock = self
5609            .ssa_request_locks
5610            .get_with(pseudonym, async { Arc::new(futures::lock::Mutex::new(())) })
5611            .await;
5612        let _request_guard = request_lock.lock().await;
5613
5614        // Successor gate, the Entry's half of the one in `SessionPixSupervisor`. A correct Exit asks
5615        // for the next batch when the *last* cycle of the current one is nearly recovered, by which
5616        // point we have long been emitting that cycle's shares. An Exit that asks earlier is asking
5617        // for deposits it cannot have earned, so nothing here commits and nothing is deposited.
5618        //
5619        // Two conditions, and the second is what the first alone cannot give. Emission must have
5620        // reached the last committed cycle — which, with the window clamped to one cycle, means every
5621        // earlier one is exhausted — *and* it must be far enough into that cycle. Checking only the
5622        // index admitted a successor batch on the last cycle's very first share, i.e. ~0 % of the way
5623        // through the batch rather than the ~86 % at which a conforming Exit asks. That is nearly a
5624        // whole cycle of unearned deposits, on a gate whose entire purpose is to prevent them.
5625        //
5626        // The boundary is derived rather than guessed — see `min_emission_for_early_recovery`, which
5627        // accounts for the Exit counting *polynomials* and for emission running in lockstep windows
5628        // that spend their whole surplus before the next window starts. A flat fraction of the cycle
5629        // gets this badly wrong in the unsafe direction: 0.85/1.5 is 57 %, against a true boundary of
5630        // 86.4 % at the deployed dimensions.
5631        //
5632        // Being early is refused rather than fatal, and deliberately not via `refuse_ssa_request`,
5633        // which closes the Session: that message is dropped and the Session left running. A correct
5634        // Exit never lands there; one that does has its own deadline for the cycles it already
5635        // allocated, and it can reach them without us having paid for anything. Lost generator state
5636        // is the one arm that *is* fatal, for the reason given at it.
5637        //
5638        // First index rather than each: `commitments` is a `BTreeMap`, so the lowest index leads, and
5639        // checking it before the loop is also what stops a batch that fails partway from having
5640        // already emitted `ReadyToDeposit` for its earlier members.
5641        //
5642        // Computed at the protocol floor rather than at our own reconstructor's threshold. The value
5643        // that decides when a correct Exit asks is *its* setting, which does not travel on the wire;
5644        // gating on ours refused a peer configured lower, silently, with no retry path — two valid
5645        // configurations that could not talk. See `MIN_EARLY_RECOVERY_THRESHOLD`, which every Exit is
5646        // held to by `validate_pix_supervision` and which is therefore the earliest any conforming
5647        // peer can ask.
5648        let min_emitted = hopr_protocol_pix::min_emission_for_early_recovery(
5649            &our_params,
5650            hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD,
5651        );
5652        match pix_toolbox.share_generator.emission_progress(&pseudonym) {
5653            Some(progress) if !progress.is_serving_last_committed() || progress.front_emitted < min_emitted => {
5654                let asked_first = msg.commitments.keys().next().copied();
5655                let error = SessionManagerError::Unacceptable(format!(
5656                    "Exit asked for SSAs from {asked_first:?} while emission has reached {:?} ({} of {min_emitted} \
5657                     shares needed for an early-recovery signal) of the batch committed up to {}",
5658                    progress.highest_emitted, progress.front_emitted, progress.last_committed
5659                ));
5660                warn!(session_id = %msg.session_id, %error, "refused an early SSA request");
5661                return Err(error.into());
5662            }
5663            // No generator state, but this Session has committed before: the entry was discarded
5664            // under us and the gate above has nothing left to measure.
5665            //
5666            // Absent state is otherwise the ordinary opening batch, which is why the gate treats it as
5667            // admissible — and that is the whole of the hole this closes. The generator keeps its
5668            // per-pseudonym entry in a cache with an idle retention, refreshed by share *emission*; a
5669            // Session kept alive on KeepAlives alone while the Entry sends nothing outlives it. An Exit
5670            // that arranges exactly that gets the successor gate deleted rather than merely relaxed —
5671            // no emission boundary, and no monotonic index either, since the discarded entry took the
5672            // high-watermark with it — and can then farm a fresh deposit per retention period.
5673            //
5674            // Terminal rather than a dropped message. The polynomials for the committed cycles went
5675            // with the entry, so `next_share` yields nothing and no share of them will ever be emitted:
5676            // whatever the Exit deposited against them is already unrecoverable and this half of the
5677            // Session can make no further PIX progress. Refusing quietly would leave the Exit to
5678            // discover that by timing out on commitments we have decided never to send.
5679            None if session_slot
5680                .current_ssa_state
5681                .get()
5682                .and_then(SessionSsaState::committed_watermark)
5683                .is_some() =>
5684            {
5685                let error = SessionManagerError::Unacceptable(format!(
5686                    "generator state for {pseudonym} was discarded while the Session was live; the cycles committed \
5687                     up to {:?} can no longer be served",
5688                    session_slot
5689                        .current_ssa_state
5690                        .get()
5691                        .and_then(SessionSsaState::committed_watermark)
5692                ));
5693                warn!(session_id = %msg.session_id, %error, "refused an SSA request against lost generator state");
5694                self.refuse_ssa_request(msg.session_id, session_slot.routing_opts.clone())
5695                    .await;
5696                return Err(error.into());
5697            }
5698            _ => {}
5699        }
5700
5701        // Second half of the successor gate, and the one that measures the Exit rather than us.
5702        //
5703        // Emission above is this node's own work: it counts shares handed to the local packet
5704        // pipeline by `create_surb_for_path`, a consumption that is not even rolled back when the
5705        // rest of the packet build fails. An Exit that requests, is funded, and then returns nothing
5706        // still walks that counter forward for as long as we keep sending, so on its own it prices
5707        // deposits against work we did to ourselves.
5708        //
5709        // What is checked here is service that actually arrived. A share is encrypted with the first
5710        // relayer's challenge solution and rides a return SURB, so the Exit can only decrypt it by
5711        // *using* that SURB — one returned packet is one SURB consumed is one share unlocked. The
5712        // Exit cannot inflate this without unlocking exactly as many shares, which advances the
5713        // recovery it is claiming to have made. Nothing it reports is trusted.
5714        //
5715        // Skipped before the first commitment: the opening batch is the one nothing has been paid
5716        // for yet, and there is no service to have been rendered against it.
5717        if let Some(state) = session_slot.current_ssa_state.get()
5718            && let Some(watermark) = state.committed_watermark()
5719        {
5720            let shares_per_cycle = our_params.polys_per_ssa() as u64 * our_params.emitted_shares_per_poly() as u64;
5721            let target = (watermark.get() as u64 - 1) * shares_per_cycle + min_emitted;
5722
5723            // Discounted by exactly the loss the surplus insures against. The Exit unlocks a share
5724            // when the *first relayer* acknowledges, which is upstream of us, so every packet lost
5725            // after that point is progress it legitimately has and we cannot see. Demanding the
5726            // undiscounted figure would refuse conforming Exits on any lossy path; an Exit losing
5727            // more than the surplus covers could not have reconstructed the cycle anyway.
5728            //
5729            // `u128` for the multiplication only — at the extremes of the accepted ranges the
5730            // numerator overflows `u64`. The quotient cannot, since the ratio is at most one.
5731            let required = (target as u128 * our_params.shares_per_poly() as u128
5732                / our_params.emitted_shares_per_poly() as u128) as u64;
5733
5734            let served = |slot: &SessionSlot| {
5735                state.served_since_first_commit(slot.returned_packets.load(std::sync::atomic::Ordering::Relaxed))
5736            };
5737            let mut observed = served(&session_slot);
5738
5739            if observed < required {
5740                // A conforming Exit asks the instant its reconstructor crosses the threshold, and the
5741                // request travels the same mixed path as the packets that earned it — so it can
5742                // overtake the last few of them. Refusing that is not a refusal at all: `RequestSsa`
5743                // is emitted once per index and never retried, so the Exit sits in
5744                // `AwaitingCommitment` until `max_ssa_delivery_time` and then closes the Session.
5745                // A short wait costs nothing and saves the Session.
5746                //
5747                // Entered only for a near miss, and that is what keeps the wait from being a lever:
5748                // this handler runs under a bounded `for_each_concurrent` and holds the
5749                // per-pseudonym request lock, so an Exit must already have returned all but one
5750                // emission window of what it owes to occupy either. Anything further out is refused
5751                // on the spot, at no cost to us.
5752                //
5753                // One window rather than a fraction of `required`: it is the unit emission advances
5754                // in, so a shortfall below it is genuinely in-flight, and a percentage would round to
5755                // zero at exactly the small dimensions where this matters most.
5756                let near_miss = observed > 0 && required - observed <= hopr_protocol_pix::SHARE_EMISSION_WINDOW as u64;
5757                if near_miss {
5758                    let deadline = Instant::now() + SSA_SUCCESSOR_SERVICE_WAIT;
5759                    while observed < required && Instant::now() < deadline {
5760                        hopr_utils::runtime::prelude::sleep(SSA_SUCCESSOR_SERVICE_POLL).await;
5761                        observed = served(&session_slot);
5762                    }
5763                }
5764            }
5765
5766            if observed < required {
5767                let error = SessionManagerError::Unacceptable(format!(
5768                    "Exit asked for SSAs having returned {observed} of the {required} packets its batch committed up \
5769                     to {watermark} has been paid for"
5770                ));
5771                warn!(session_id = %msg.session_id, %error, "refused an under-served SSA request");
5772                return Err(error.into());
5773            }
5774        }
5775
5776        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
5777        let session_id = msg.session_id;
5778
5779        // The server can theoretically send multiple SSA commitments asking us to make the equal
5780        // number of client commitments and deposits, and the batch is all-or-nothing: either every
5781        // member gets a commitment and a `ReadyToDeposit`, or none does.
5782        //
5783        // Hence three phases rather than one loop. Interleaving them meant a batch whose *second*
5784        // exit commitment failed to decode had already sent the first member's `SsaCommit` burst and
5785        // emitted its `ReadyToDeposit` — an on-chain deposit instruction — before the failure was
5786        // reached. The Exit, whose own request was rejected as a whole, has no cycle to spend it on.
5787        //
5788        // The server is authoritative in giving the ssa_index; the client only verifies that it is
5789        // strictly monotonic. That monotonicity is enforced inside `new_ssa_commitment` in phase two,
5790        // which rejects any `ssa_index` that is `<=` the last one generated for this pseudonym with
5791        // `PixError::InvalidInput` (see `SsaShareGenerator::new_ssa_commitment`). Because that call
5792        // happens in a phase before anything is published, a stale, duplicate, or out-of-order
5793        // `SsaRequest` cannot cause a deposit — the whole message is rejected first. The
5794        // per-pseudonym baseline lives in the generator's `polynomials` cache (30-min idle TTL,
5795        // refreshed on every use), so it persists for the life of an active session. Gaps (an index
5796        // strictly greater than the last, but not the immediate successor) are allowed by design,
5797        // since the Exit may advance by more than one SSA at a time.
5798
5799        // Phase 1 — validate. Decoding is the only step that consumes attacker-supplied bytes:
5800        // `try_into_pix_group` decompresses the point and rejects anything outside the prime-order
5801        // subgroup. Doing every one of them up front is what makes a malformed later member cost
5802        // nothing, and it is cheap relative to phase two.
5803        // Taken out before the loop consumes `msg.commitments`, and drained as the loop goes: an SSA
5804        // is committed to once, so its deposit data is needed once.
5805        let mut deposit_payloads = msg.deposit_data;
5806
5807        let mut validated = Vec::with_capacity(msg.commitments.len());
5808        for (ssa_index, exit_commitment) in msg.commitments {
5809            trace!(ssa_index, "received Exit SSA commitment");
5810            match exit_commitment.try_into_pix_group() {
5811                Ok(point) => validated.push((ssa_index, point)),
5812                Err(error) => {
5813                    // Terminal, like every other unacceptable-parameter case here. A peer that cannot
5814                    // produce a valid group element is not going to produce one on a retry, and the
5815                    // Exit would otherwise wait out `max_ssa_delivery_time` on commitments that are
5816                    // never coming.
5817                    let error = SessionManagerError::Unacceptable(format!(
5818                        "Exit sent an undecodable SSA commitment for index {ssa_index}: {error}"
5819                    ));
5820                    self.refuse_ssa_request(session_id, session_slot.routing_opts.clone())
5821                        .await;
5822                    return Err(error.into());
5823                }
5824            }
5825        }
5826
5827        // Phase 2 — stage. Generates every client commitment and derives every deposit address,
5828        // publishing none of them. This is the expensive phase, and it still mutates the generator:
5829        // `new_ssa_commitment` appends to the per-pseudonym polynomial queue. Failing here therefore
5830        // leaves queued polynomials the Exit never learns of — wasted work rather than a leaked
5831        // deposit instruction, and the generator's own monotonic index keeps them from being mistaken
5832        // for a later cycle's.
5833        let mut staged = Vec::with_capacity(validated.len());
5834        for (ssa_index, exit_point) in validated {
5835            // Use the global `pix_toolbox.share_generator` to generate the client
5836            // commitment. The generator is shared with the packet pipeline's
5837            // `next_share`, so polynomials created here will be used when the
5838            // pipeline embeds PIX shares into return-path SURBs.
5839            //
5840            // The generator dimension (polys × threshold) must match what the
5841            // Exit's reconstructor expects — both are set from the session's
5842            // negotiated PIX params (pix_global_config on Entry → SsaRequest
5843            // params on Exit).  If the client sends commitments that exceed the
5844            // Exit's expected dimensions, the Exit rejects them as InvalidInput.
5845            let pix_toolbox_clone = pix_toolbox.clone();
5846            let client_commitment = hopr_utils::parallelize::cpu::spawn_blocking(
5847                move || {
5848                    pix_toolbox_clone
5849                        .share_generator
5850                        .new_ssa_commitment(&pseudonym, ssa_index)
5851                },
5852                "client_ssa_commitment",
5853            )
5854            .await
5855            .map_err(SessionManagerError::other)?
5856            .map_err(SessionManagerError::PixError)?;
5857
5858            // The generator has been advanced for this index, so the Session records it now rather
5859            // than after the publish phase. Recording it early is the safe direction: the watermark
5860            // only ever makes the successor gate stricter, and a batch abandoned between here and
5861            // publication has still moved the generator.
5862            if let Some(state) = session_slot.current_ssa_state.get() {
5863                state.note_committed(
5864                    ssa_index,
5865                    session_slot.returned_packets.load(std::sync::atomic::Ordering::Relaxed),
5866                );
5867            }
5868
5869            // Construct the full SSA by adding the client and exit commitments, getting the deposit address
5870            let full_ssa = client_commitment.ssa_commitment + exit_point;
5871            let deposit_address = HoprPixSpec::group_to_deposit_address(full_ssa).ok_or(SessionManagerError::other(
5872                anyhow::anyhow!("failed to convert SSA to deposit address"),
5873            ))?;
5874
5875            // Split the SSA client commitment into Start protocol commitment messages
5876            let commitment_msgs = SsaClientCommitmentMessage::new_multiple(session_id, client_commitment)
5877                .map_err(SessionManagerError::other)?;
5878            debug!(%ssa_index, count = commitment_msgs.len(), "generated client SSA commitment messages");
5879
5880            staged.push((ssa_index, deposit_address, commitment_msgs));
5881        }
5882
5883        // Phase 3 — publish. Nothing below can fail on the *content* of the request; only the
5884        // transport can, and a transport that has stopped accepting messages fails the Session
5885        // regardless of where in the batch it happens.
5886        for (ssa_index, deposit_address, commitment_msgs) in staged {
5887            // Send each commitment message into the message sender
5888            for commitment_msg in commitment_msgs {
5889                send_via_msg_sender(
5890                    &mut msg_sender,
5891                    session_slot.routing_opts.clone(),
5892                    HoprStartProtocol::SsaCommit(commitment_msg),
5893                    "client SSA commitment message",
5894                )
5895                .await?;
5896            }
5897
5898            debug!(%ssa_index, "all Entry SSA commitment messages were sent out");
5899
5900            // Notify the new SSA deposit address *after* all commitment messages have been
5901            // sent out successfully, so the deposit cannot begin before the Exit has the
5902            // complete commitment to reconstruct the deposit key.
5903            pix_toolbox
5904                .pix_events
5905                .try_send(HoprSessionOutPixEvent::ReadyToDeposit(AgreedSsaQuota {
5906                    ssa_id: SsaId::new(pseudonym, ssa_index),
5907                    deposit_address,
5908                    quota_per_ssa,
5909                    // Rebuilt rather than carried: the `id` is not on the wire, because this message
5910                    // already says which Session and which SSA these bytes belong to.
5911                    deposit_data: PixDepositData {
5912                        id: PixAddressId::new(&pseudonym, ssa_index),
5913                        data: deposit_payloads
5914                            .remove(&ssa_index)
5915                            .map(|payload| payload.0)
5916                            .unwrap_or_default(),
5917                    },
5918                }))
5919                .map_err(|_| SessionManagerError::other(anyhow::anyhow!("failed to notify new deposit ssa")))?;
5920            info!(%ssa_index, %deposit_address, quota_per_ssa, "generated client SSA commitment and deposit address");
5921        }
5922
5923        trace!(quota_per_ssa, "Exit commitment message has been fully processed");
5924        Ok(())
5925    }
5926}
5927
5928#[cfg(test)]
5929mod tests {
5930    use anyhow::{Context, anyhow};
5931    use futures::{AsyncWriteExt, channel::mpsc::UnboundedSender, future::BoxFuture, pin_mut};
5932    use hopr_api::types::{
5933        crypto::{keypairs::ChainKeypair, prelude::Keypair},
5934        crypto_random::Randomizable,
5935        internal::routing::SurbMatcher,
5936        primitive::prelude::Address,
5937    };
5938    use hopr_crypto_packet::prelude::{PacketSignal, PacketSignals};
5939    use hopr_protocol_pix::{SsaGeneratorConfig, SsaIndex, SsaReconstructorConfig};
5940    use hopr_protocol_start::{StartProtocol, StartProtocolDiscriminants};
5941    use hopr_utils::network_types::prelude::SealedHost;
5942    use moka::future::FutureExt;
5943    use tokio::time::timeout;
5944
5945    use super::*;
5946    use crate::{Capabilities, balancer::SurbBalancerConfig, types::SessionTarget};
5947
5948    /// A [`PixToolbox`] whose deposit-data requests are answered, for tests that do not read the
5949    /// PIX event stream themselves.
5950    ///
5951    /// An Exit cannot send an `SsaRequest` without the pool answering, so a test that only needs the
5952    /// request to *happen* would otherwise be asserting against `MissingDepositData`.
5953    fn pix_toolbox_with_pool(
5954        generator: Arc<SsaShareGenerator<HoprPixSpec>>,
5955        reconstructor: Arc<SsaReconstructor<HoprPixSpec>>,
5956    ) -> PixToolbox {
5957        let (toolbox, events) = PixToolbox::new(generator, reconstructor);
5958        // The answering task owns `events`, and lives until the toolbox is dropped. The forwarded
5959        // stream is discarded: these tests do not read PIX events.
5960        drop(crate::testing::answering_deposit_pool(events, |_| Vec::new()));
5961        toolbox
5962    }
5963
5964    /// `StartInitiation::additional_data` as an Entry offering these dimensions would send it.
5965    ///
5966    /// Tests go through the same packing production does. They used to write the shifts out by
5967    /// hand, which meant a change to the layout altered what every one of them was asserting
5968    /// without altering a single line of them — plain `u64` literals type-check against anything.
5969    fn pix_additional_data(polys_per_ssa: u16, shares_per_poly: u8, surplus_shares: u8) -> u128 {
5970        into_initiation_additional_data(
5971            PixParams::try_new(polys_per_ssa, shares_per_poly, surplus_shares, LOCAL_PIX_SUITE)
5972                .expect("test dimensions must be valid")
5973                .into_additional_data(0),
5974        )
5975    }
5976
5977    /// The default test dimensions: the smallest legal split, with the surplus the test generators
5978    /// below are configured with.
5979    fn small_pix_params() -> PixParams {
5980        PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE).expect("test dimensions must be valid")
5981    }
5982
5983    /// [`small_pix_params`] as an Entry offering them would pack them into `additional_data`.
5984    fn small_pix_additional_data() -> u128 {
5985        into_initiation_additional_data(small_pix_params().into_additional_data(0))
5986    }
5987
5988    /// Surplus used by the small test `SsaGeneratorConfig`s below. Non-zero and different from
5989    /// [`SsaGeneratorConfig::default`], so a value that failed to cross the wire is visible.
5990    const TEST_SURPLUS_SHARES: u8 = 1;
5991
5992    #[test]
5993    fn session_config_forwards_max_buffered_segments() {
5994        assert_eq!(
5995            SessionManagerConfig::default().max_buffered_segments,
5996            0,
5997            "default must leave the transport unbuffered"
5998        );
5999
6000        for segments in [0, 64] {
6001            let cfg = SessionManagerConfig {
6002                max_buffered_segments: segments,
6003                ..Default::default()
6004            };
6005            assert_eq!(
6006                session_config(&cfg, Capabilities::empty()).max_buffered_segments,
6007                segments
6008            );
6009        }
6010    }
6011
6012    /// The head-of-line bound must reach a session that cannot recover a missing frame, and must
6013    /// not reach one that can.
6014    ///
6015    /// Both halves matter. Without the first, a session with no retransmission holds every frame
6016    /// behind a gap for the full frame timeout, waiting for something that is never coming —
6017    /// measured on a cluster as 98.5 % of bytes arriving over the wire and 0.60 % reaching the
6018    /// application. Without the second, a session that *would* have retransmitted the gap instead
6019    /// abandons it, turning recoverable frames into loss.
6020    #[test]
6021    fn session_config_should_bound_the_gap_only_without_retransmission() {
6022        assert_eq!(
6023            SessionManagerConfig::default().max_frames_behind_gap,
6024            Some(256),
6025            "the default must bound the gap, or the stall stays in place unless opted out of"
6026        );
6027
6028        let cfg = SessionManagerConfig {
6029            max_frames_behind_gap: Some(8),
6030            ..Default::default()
6031        };
6032
6033        for reliable in [Capability::RetransmissionAck, Capability::RetransmissionNack] {
6034            assert_eq!(
6035                session_config(&cfg, reliable.into()).max_frames_behind_gap,
6036                None,
6037                "{reliable:?} can recover the gap, so the wait is productive and must be left alone"
6038            );
6039        }
6040
6041        for unreliable in [Capabilities::empty(), Capability::Segmentation.into()] {
6042            assert_eq!(
6043                session_config(&cfg, unreliable).max_frames_behind_gap,
6044                Some(8),
6045                "without retransmission the gap must be bounded"
6046            );
6047        }
6048    }
6049
6050    /// The right value tracks throughput x latency spread, which is a property of the *session*,
6051    /// not of the node: a bulk data session and a control session on the same node have entirely
6052    /// different reordering depths. A caller that knows its own traffic must be able to say so.
6053    #[test]
6054    fn a_session_should_be_able_to_override_the_nodes_gap_bound() {
6055        let node = SessionManagerConfig {
6056            max_frames_behind_gap: Some(256),
6057            ..Default::default()
6058        };
6059
6060        assert_eq!(
6061            session_config_with(&node, Capabilities::empty(), Some(16)).max_frames_behind_gap,
6062            Some(16),
6063            "the session's own value must win over the node default"
6064        );
6065        assert_eq!(
6066            session_config_with(&node, Capabilities::empty(), None).max_frames_behind_gap,
6067            Some(256),
6068            "saying nothing must inherit the node default"
6069        );
6070        assert_eq!(
6071            session_config_with(&node, Capabilities::empty(), Some(0)).max_frames_behind_gap,
6072            None,
6073            "zero disables the bound for this session, matching the env knob's semantics"
6074        );
6075    }
6076
6077    /// A per-session override must not be able to re-enable the bound where waiting is productive.
6078    #[test]
6079    fn a_session_override_should_not_reach_a_session_that_can_retransmit() {
6080        let node = SessionManagerConfig::default();
6081        assert_eq!(
6082            session_config_with(&node, Capability::RetransmissionAck.into(), Some(4)).max_frames_behind_gap,
6083            None,
6084            "retransmission can recover the gap, so no caller should be able to cut the wait short"
6085        );
6086    }
6087
6088    /// Disabling has to be reachable, since the bound trades reordering tolerance for latency and
6089    /// the right value is deployment-specific. `None` restores the previous behaviour exactly.
6090    #[test]
6091    fn session_config_should_allow_the_gap_bound_to_be_disabled() {
6092        let cfg = SessionManagerConfig {
6093            max_frames_behind_gap: None,
6094            ..Default::default()
6095        };
6096        assert_eq!(session_config(&cfg, Capabilities::empty()).max_frames_behind_gap, None);
6097    }
6098
6099    #[test]
6100    fn a_zero_gap_bound_should_disable_it_at_the_node_level_too() {
6101        // `0` means "not for me" wherever it is written. Read literally it would be the strictest
6102        // possible bound -- abandon the gap before a single frame arrives behind it -- so the two
6103        // levels would mean opposite things by the same value.
6104        let cfg = SessionManagerConfig {
6105            max_frames_behind_gap: Some(0),
6106            ..Default::default()
6107        };
6108        assert_eq!(session_config(&cfg, Capabilities::empty()).max_frames_behind_gap, None);
6109    }
6110
6111    /// Balancer state with the given target sitting at the given level.
6112    fn gate_state(target: u64, buffer_level: u64) -> BalancerStateValues {
6113        let state = BalancerStateValues::new(SurbBalancerConfig {
6114            target_surb_buffer_size: target,
6115            ..Default::default()
6116        });
6117        state
6118            .buffer_level
6119            .store(buffer_level, std::sync::atomic::Ordering::Relaxed);
6120        state
6121    }
6122
6123    /// A packet short enough to have room for a SURB. `max_surbs_with_message` is
6124    /// `(PAYLOAD_SIZE - len) / SURB_SIZE`, so anything past roughly half the payload already caps
6125    /// itself at zero and would make the assertions below pass for the wrong reason.
6126    fn small_outgoing_packet() -> anyhow::Result<ApplicationDataOut> {
6127        let data = ApplicationDataOut::with_no_packet_info(ApplicationData::new(SESSION_APPLICATION_TAG, b"short")?);
6128        anyhow::ensure!(
6129            data.estimate_surbs_with_msg() >= 1,
6130            "fixture must have room for a SURB, otherwise the cap is not what is being measured"
6131        );
6132        Ok(data)
6133    }
6134
6135    /// `account_incoming_surbs` must keep the estimator's held count (`saturating_diff`) equal to
6136    /// what a bounded store of capacity `CAP` would actually hold across a fill-past-capacity-then-drain
6137    /// sequence. The eviction term is what makes the held count fall back to zero once the overflow is
6138    /// drained -- and so what stops the keep-alive report and the PIX fill reserve from reading a level
6139    /// that never falls.
6140    #[test]
6141    fn account_incoming_surbs_tracks_a_bounded_store_through_overflow_and_drain() {
6142        const CAP: u64 = 100;
6143        let estimator = AtomicSurbFlowEstimator::default();
6144        let mut occupancy: u64 = 0;
6145
6146        let mut receive = |batch: u64| {
6147            let evicted = (occupancy + batch).saturating_sub(CAP);
6148            occupancy = (occupancy + batch).min(CAP);
6149            account_incoming_surbs(&estimator, batch, evicted);
6150        };
6151
6152        for _ in 0..5 {
6153            receive(40);
6154        }
6155        assert_eq!(occupancy, CAP, "the model store is full");
6156        assert_eq!(
6157            estimator.saturating_diff(),
6158            CAP,
6159            "held count tracks a full store, not the {}-SURB total received",
6160            5 * 40
6161        );
6162
6163        for _ in 0..CAP {
6164            estimator.consumed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6165            occupancy -= 1;
6166        }
6167        assert_eq!(occupancy, 0, "the model store is empty");
6168        assert_eq!(
6169            estimator.saturating_diff(),
6170            0,
6171            "held count returns to zero after the drain; the evicted surplus is not counted as held"
6172        );
6173    }
6174
6175    /// The gate primitive itself mints whenever the level is below the effective target. A level
6176    /// short of target with no capacity inferred yet still mints -- the stop for a smaller-than-target
6177    /// store comes from inferring its capacity over successive reports (see
6178    /// `a_plateau_below_target_infers_the_counterparty_capacity` in `balancer/controller.rs`), which
6179    /// lowers the effective target rather than changing this primitive.
6180    #[test]
6181    fn organic_minting_continues_below_target_without_an_inferred_capacity() -> anyhow::Result<()> {
6182        const EXIT_CAPACITY: u64 = 500;
6183        const ENTRY_TARGET: u64 = 2_000;
6184
6185        let mut data = small_outgoing_packet()?;
6186        cap_organic_surbs(&mut data, 1, &gate_state(ENTRY_TARGET, EXIT_CAPACITY));
6187
6188        assert_eq!(Some(1), data.packet_info.map(|i| i.max_surbs_in_packet));
6189        assert_eq!(
6190            1,
6191            data.estimate_surbs_with_msg(),
6192            "below the effective target with no inferred capacity, the Entry mints"
6193        );
6194        Ok(())
6195    }
6196
6197    #[test]
6198    fn cap_organic_surbs_should_zero_the_packet_once_the_counterparty_is_at_target() -> anyhow::Result<()> {
6199        let mut data = small_outgoing_packet()?;
6200        cap_organic_surbs(&mut data, 1, &gate_state(100, 200));
6201
6202        assert_eq!(Some(0), data.packet_info.map(|i| i.max_surbs_in_packet));
6203        assert_eq!(
6204            0,
6205            data.estimate_surbs_with_msg(),
6206            "the cap must reach the SURB accounting, not just the packet info"
6207        );
6208        Ok(())
6209    }
6210
6211    #[test]
6212    fn cap_organic_surbs_should_allow_one_while_the_counterparty_is_below_target() -> anyhow::Result<()> {
6213        let mut data = small_outgoing_packet()?;
6214        cap_organic_surbs(&mut data, 1, &gate_state(100, 10));
6215
6216        assert_eq!(Some(1), data.packet_info.map(|i| i.max_surbs_in_packet));
6217        assert_eq!(1, data.estimate_surbs_with_msg());
6218        Ok(())
6219    }
6220
6221    /// The cap writes into a field it may have to create, so it must not take the rest of the packet
6222    /// info with it — the routing stage sets the outgoing signals on this same struct.
6223    #[test]
6224    fn cap_organic_surbs_should_not_clobber_existing_packet_info() -> anyhow::Result<()> {
6225        let mut data = small_outgoing_packet()?;
6226        data.packet_info = Some(OutgoingPacketInfo {
6227            signals_to_destination: PacketSignal::SurbDistress.into(),
6228            max_surbs_in_packet: usize::MAX,
6229            surb_generation: Some(7),
6230        });
6231
6232        cap_organic_surbs(&mut data, 1, &gate_state(100, 200));
6233
6234        let info = data.packet_info.expect("packet info must survive");
6235        assert_eq!(0, info.max_surbs_in_packet);
6236        assert_eq!(
6237            PacketSignals::from(PacketSignal::SurbDistress),
6238            info.signals_to_destination
6239        );
6240        assert_eq!(
6241            Some(7),
6242            info.surb_generation,
6243            "the generation captured at path resolution must survive the cap"
6244        );
6245        Ok(())
6246    }
6247
6248    /// `usize::MAX` is an explicit statement by the client about its own traffic, so it
6249    /// wins over the balancer's estimate — and must leave the packet entirely untouched rather than
6250    /// writing some larger cap.
6251    #[test]
6252    fn maxing_out_surbs_should_bypass_the_balancer_gate() -> anyhow::Result<()> {
6253        let mut data = small_outgoing_packet()?;
6254        cap_organic_surbs(&mut data, usize::MAX, &gate_state(100, 200));
6255
6256        assert_eq!(None, data.packet_info, "the opt-in must not touch the packet at all");
6257        assert!(data.estimate_surbs_with_msg() >= 1);
6258        Ok(())
6259    }
6260
6261    #[test]
6262    fn cap_organic_surbs_should_honor_explicit_packet_limits() -> anyhow::Result<()> {
6263        for limit in [0, 2] {
6264            for buffer_level in [10, 200] {
6265                let mut data = small_outgoing_packet()?;
6266                cap_organic_surbs(&mut data, limit, &gate_state(100, buffer_level));
6267
6268                assert_eq!(Some(limit), data.packet_info.map(|info| info.max_surbs_in_packet));
6269                assert_eq!(limit, data.estimate_surbs_with_msg());
6270            }
6271        }
6272        Ok(())
6273    }
6274
6275    #[async_trait::async_trait]
6276    trait SendMsg {
6277        async fn send_message(
6278            &self,
6279            routing: DestinationRouting,
6280            data: ApplicationDataOut,
6281        ) -> crate::errors::Result<()>;
6282    }
6283
6284    mockall::mock! {
6285        MsgSender {}
6286        impl SendMsg for MsgSender {
6287            fn send_message<'a, 'b>(&'a self, routing: DestinationRouting, data: ApplicationDataOut)
6288            -> BoxFuture<'b, crate::errors::Result<()>> where 'a: 'b, Self: Sync + 'b;
6289        }
6290    }
6291
6292    fn mock_packet_planning(
6293        sender: MockMsgSender,
6294    ) -> (
6295        UnboundedSender<(DestinationRouting, ApplicationDataOut)>,
6296        tokio::task::JoinHandle<anyhow::Result<()>>,
6297    ) {
6298        let (tx, rx) = futures::channel::mpsc::unbounded();
6299        let handle = tokio::task::spawn(async move {
6300            pin_mut!(rx);
6301            while let Some((routing, data)) = rx.next().await {
6302                sender.send_message(routing, data).await?;
6303            }
6304            Ok(())
6305        });
6306        (tx, handle)
6307    }
6308
6309    fn msg_type(data: &ApplicationDataOut, expected: StartProtocolDiscriminants) -> bool {
6310        HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
6311            .map(|d| StartProtocolDiscriminants::from(d) == expected)
6312            .unwrap_or(false)
6313    }
6314
6315    fn start_msg_match(data: &ApplicationDataOut, msg: impl Fn(HoprStartProtocol) -> bool) -> bool {
6316        HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
6317            .map(msg)
6318            .unwrap_or(false)
6319    }
6320
6321    /// Waits (bounded) until the manager reports no active sessions.
6322    ///
6323    /// The session-slot rollback runs on a spawned task, so its effect is observed
6324    /// asynchronously; this polls [`SessionManager::active_sessions`] until it drains.
6325    async fn wait_for_no_active_sessions(
6326        mgr: &SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>,
6327    ) -> bool {
6328        for _ in 0..50 {
6329            if mgr.active_sessions().is_empty() {
6330                return true;
6331            }
6332            tokio::time::sleep(Duration::from_millis(20)).await;
6333        }
6334        mgr.active_sessions().is_empty()
6335    }
6336
6337    const SESSION_FORWARD_CAPACITY: usize = 10000;
6338
6339    /// Verifies that a session's SURB balancer config can be retrieved and updated via the manager API.
6340    ///
6341    /// ## Steps
6342    /// 1. A session slot is manually inserted into Alice's manager with a known `SurbBalancerConfig`
6343    ///    (`target_surb_buffer_size: 1000`, `max_surbs_per_sec: 100`).
6344    /// 2. `get_surb_balancer_config` returns the config, confirming round-trip storage.
6345    /// 3. `update_surb_balancer_config` is called with a new config (`target: 2000`, `max: 200`).
6346    /// 4. `get_surb_balancer_config` is called again and the returned config matches the updated values.
6347    #[test_log::test(tokio::test)]
6348    async fn session_manager_should_update_surb_balancer_config() -> anyhow::Result<()> {
6349        let alice_pseudonym = HoprPseudonym::random();
6350        let session_id = alice_pseudonym;
6351        let balancer_cfg = SurbBalancerConfig {
6352            target_surb_buffer_size: 1000,
6353            max_surbs_per_sec: 100,
6354            ..Default::default()
6355        };
6356
6357        let alice_mgr =
6358            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
6359
6360        let (dummy_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
6361        alice_mgr.sessions.insert(
6362            session_id,
6363            SessionSlot {
6364                session_tx: dummy_tx,
6365                routing_opts: DestinationRouting::Return(SurbMatcher::Pseudonym(alice_pseudonym)),
6366                abort_handles: Default::default(),
6367                surb_mgmt: Arc::new(BalancerStateValues::from(balancer_cfg)),
6368                surb_estimator: Default::default(),
6369                current_ssa_state: Default::default(),
6370                pix_supervisor: Default::default(),
6371                pix_egress_gate: Default::default(),
6372                pix_fill: Default::default(),
6373                returned_packets: Default::default(),
6374                cycle_budget: None,
6375            },
6376        );
6377
6378        let actual_cfg = alice_mgr
6379            .get_surb_balancer_config(&session_id)?
6380            .ok_or(anyhow!("session must have a surb balancer config"))?;
6381        assert_eq!(actual_cfg, balancer_cfg);
6382
6383        let new_cfg = SurbBalancerConfig {
6384            target_surb_buffer_size: 2000,
6385            max_surbs_per_sec: 200,
6386            ..Default::default()
6387        };
6388        alice_mgr.update_surb_balancer_config(&session_id, new_cfg)?;
6389
6390        let actual_cfg = alice_mgr
6391            .get_surb_balancer_config(&session_id)?
6392            .ok_or(anyhow!("session must have a surb balancer config"))?;
6393        assert_eq!(actual_cfg, new_cfg);
6394
6395        Ok(())
6396    }
6397
6398    /// Verifies that a self-initiated session is rejected with `SessionManagerError::Loopback`.
6399    ///
6400    /// ## Steps
6401    /// 1. Alice's manager is started with a mock transport that delivers messages back to itself.
6402    /// 2. Alice initiates a session toward `bob_peer`; the mock routes her `StartSession` back to her own manager
6403    ///    (simulating a network loopback).
6404    /// 3. Alice's manager processes `StartSession` as incoming, auto-responds with `SessionEstablished`, and the mock
6405    ///    delivers it back to complete the handshake.
6406    /// 4. `new_session` returns `Err(TransportSessionError::Manager(SessionManagerError::Loopback))`.
6407    /// 5. Exactly one active session is present — the incoming slot accepted from the self-delivered `StartSession`.
6408    ///    The rejection fires after slot insertion, not before.
6409    #[test_log::test(tokio::test)]
6410    async fn session_manager_should_not_allow_loopback_sessions() -> anyhow::Result<()> {
6411        let alice_pseudonym = HoprPseudonym::random();
6412        let bob_peer: Address = (&ChainKeypair::random()).into();
6413
6414        let alice_mgr = SessionManager::new(Default::default());
6415
6416        let mut sequence = mockall::Sequence::new();
6417        let mut alice_transport = MockMsgSender::new();
6418
6419        // Alice sends the StartSession message
6420        let alice_mgr_clone = alice_mgr.clone();
6421        alice_transport
6422            .expect_send_message()
6423            .once()
6424            .in_sequence(&mut sequence)
6425            .withf(move |peer, data| {
6426                msg_type(data, StartProtocolDiscriminants::StartSession)
6427                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
6428            })
6429            .returning(move |_, data| {
6430                // But the message is again processed by Alice due to Loopback
6431                let alice_mgr_clone = alice_mgr_clone.clone();
6432                Box::pin(async move {
6433                    alice_mgr_clone
6434                        .dispatch_message(
6435                            alice_pseudonym,
6436                            ApplicationDataIn {
6437                                data: data.data,
6438                                packet_info: Default::default(),
6439                            },
6440                        )
6441                        ?;
6442                    Ok(())
6443                })
6444            });
6445
6446        // Alice sends the SessionEstablished message (as Bob)
6447        let alice_mgr_clone = alice_mgr.clone();
6448        alice_transport
6449            .expect_send_message()
6450            .once()
6451            .in_sequence(&mut sequence)
6452            .withf(move |peer, data| {
6453                msg_type(data, StartProtocolDiscriminants::SessionEstablished)
6454                    && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if *p == alice_pseudonym)
6455            })
6456            .returning(move |_, data| {
6457                let alice_mgr_clone = alice_mgr_clone.clone();
6458
6459                Box::pin(async move {
6460                    alice_mgr_clone.dispatch_message(
6461                        alice_pseudonym,
6462                        ApplicationDataIn {
6463                            data: data.data,
6464                            packet_info: Default::default(),
6465                        },
6466                    )?;
6467                    Ok(())
6468                })
6469            });
6470
6471        // Start Alice
6472        let (new_session_tx_alice, new_session_rx_alice) = futures::channel::mpsc::channel(1024);
6473        let (alice_sender, alice_handle) = mock_packet_planning(alice_transport);
6474        alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?;
6475        assert!(alice_mgr.is_started());
6476
6477        let alice_session = alice_mgr
6478            .new_session(
6479                bob_peer,
6480                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6481                SessionClientConfig {
6482                    capabilities: None.into(),
6483                    pseudonym: alice_pseudonym.into(),
6484                    surb_management: None,
6485                    ..Default::default()
6486                },
6487            )
6488            .await;
6489
6490        println!("{alice_session:?}");
6491        assert!(matches!(
6492            alice_session,
6493            Err(TransportSessionError::Manager(SessionManagerError::Loopback))
6494        ));
6495        // There is one session in the manager, which is the incoming one that Alice's manager
6496        // accepted when it received the StartSession message from itself.
6497        assert_eq!(alice_mgr.num_active_sessions(), 1);
6498
6499        drop(new_session_rx_alice);
6500
6501        // Cleanup: close sender and await handle
6502        alice_sender.close_channel();
6503        alice_handle.await??;
6504
6505        Ok(())
6506    }
6507
6508    /// Verifies that a session initiation returns `TransportSessionError::Timeout` when the peer
6509    /// never processes or responds to the `StartSession` message.
6510    ///
6511    /// ## Steps
6512    /// 1. Alice's manager is configured with `initiation_timeout_base: 100ms`. Bob's manager is started but its mock
6513    ///    transport silently swallows all messages (never dispatches to the manager).
6514    /// 2. Alice calls `new_session`; her `StartSession` is captured by the mock and silently discarded.
6515    /// 3. The 100ms timeout expires; `new_session` returns `Err(TransportSessionError::Timeout)`.
6516    /// 4. `num_active_sessions` is 0, confirming no orphaned slot was left in the cache.
6517    #[test_log::test(tokio::test)]
6518    async fn session_manager_should_timeout_new_session_attempt_when_no_response() -> anyhow::Result<()> {
6519        let bob_peer: Address = (&ChainKeypair::random()).into();
6520
6521        let cfg = SessionManagerConfig {
6522            initiation_timeout_base: Duration::from_millis(100),
6523            ..Default::default()
6524        };
6525
6526        let alice_mgr = SessionManager::new(cfg);
6527        let bob_mgr = SessionManager::new(Default::default());
6528
6529        let mut sequence = mockall::Sequence::new();
6530        let mut alice_transport = MockMsgSender::new();
6531        let bob_transport = MockMsgSender::new();
6532
6533        // Alice sends the StartSession message, but Bob does not handle it
6534        alice_transport
6535            .expect_send_message()
6536            .once()
6537            .in_sequence(&mut sequence)
6538            .withf(move |peer, data| {
6539                msg_type(data, StartProtocolDiscriminants::StartSession)
6540                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
6541            })
6542            .returning(|_, _| Box::pin(async { Ok(()) }));
6543
6544        // Start Alice
6545        let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
6546        let (alice_sender, _alice_handle) = mock_packet_planning(alice_transport);
6547        alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?;
6548        assert!(alice_mgr.is_started());
6549
6550        // Start Bob
6551        let (new_session_tx_bob, _) = futures::channel::mpsc::channel(1024);
6552        let (bob_sender, _bob_handle) = mock_packet_planning(bob_transport);
6553        bob_mgr.start(bob_sender.clone(), new_session_tx_bob, None, None)?;
6554        assert!(bob_mgr.is_started());
6555
6556        let result = alice_mgr
6557            .new_session(
6558                bob_peer,
6559                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6560                SessionClientConfig {
6561                    capabilities: None.into(),
6562                    pseudonym: None,
6563                    surb_management: None,
6564                    ..Default::default()
6565                },
6566            )
6567            .await;
6568
6569        assert!(matches!(result, Err(TransportSessionError::Timeout)));
6570        assert_eq!(alice_mgr.num_active_sessions(), 0);
6571
6572        Ok(())
6573    }
6574
6575    /// Verifies that a failed incoming session establishment does not register any telemetry.
6576    ///
6577    /// ## Steps
6578    /// 1. A `SessionManager` is started with the `telemetry` feature enabled.
6579    /// 2. The new-session notification channel's receiver is dropped immediately, so notifying about a new incoming
6580    ///    session will fail.
6581    /// 3. `handle_incoming_session_initiation` is called with a random pseudonym. The slot is inserted first, then
6582    ///    notifying about the new session fails (receiver is gone).
6583    /// 4. `wait_for_no_active_sessions` polls until there are no active sessions, confirming the partially-inserted
6584    ///    slot was rolled back.
6585    /// 5. `num_active_sessions` is 0, proving the rollback prevented any telemetry registration for the failed session.
6586    #[cfg(feature = "telemetry")]
6587    #[test_log::test(tokio::test)]
6588    async fn failed_incoming_session_establishment_does_not_register_telemetry() -> anyhow::Result<()> {
6589        let mgr = SessionManager::new(Default::default());
6590
6591        let transport = MockMsgSender::new();
6592        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
6593        drop(new_session_rx);
6594        let (sender, _handle) = mock_packet_planning(transport);
6595        mgr.start(sender.clone(), new_session_tx, None, None)?;
6596        assert!(mgr.is_started());
6597
6598        let pseudonym = HoprPseudonym::random();
6599        let result = mgr
6600            .handle_incoming_session_initiation(
6601                pseudonym,
6602                StartInitiation {
6603                    challenge: MIN_CHALLENGE,
6604                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6605                    capabilities: HoprSessionCapabilities(Capabilities::empty()),
6606                    additional_data: 0,
6607                },
6608            )
6609            .await;
6610
6611        assert!(result.is_err());
6612
6613        // The slot inserted before the failure must be rolled back, so it neither
6614        // counts towards `maximum_sessions` nor registers any telemetry.
6615        assert!(
6616            wait_for_no_active_sessions(&mgr).await,
6617            "the partially established session slot was not rolled back"
6618        );
6619        assert_eq!(mgr.num_active_sessions(), 0);
6620
6621        // Cleanup: close sender and await handle
6622        sender.close_channel();
6623        _handle.await??;
6624
6625        Ok(())
6626    }
6627
6628    /// Verifies that a session slot is rolled back if session setup fails after the slot is inserted.
6629    ///
6630    /// ## Steps
6631    /// 1. A `SessionManager` is started; the new-session notification channel's receiver is dropped, so notifying about
6632    ///    a new incoming session will fail.
6633    /// 2. `handle_incoming_session_initiation` is called with a random pseudonym. The slot is inserted into the cache
6634    ///    first, then notifying about the new session fails (because the receiver is gone).
6635    /// 3. The call returns an error, and `wait_for_no_active_sessions` confirms the slot was removed.
6636    /// 4. `num_active_sessions` is 0, proving the rollback removed the slot and freed the pseudonym.
6637    #[test_log::test(tokio::test)]
6638    async fn session_manager_should_roll_back_slot_when_incoming_session_setup_fails() -> anyhow::Result<()> {
6639        let mgr = SessionManager::new(Default::default());
6640
6641        // Drop the receiver so that notifying about the new incoming session fails
6642        // *after* the session slot has already been inserted into the cache.
6643        let transport = MockMsgSender::new();
6644        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
6645        drop(new_session_rx);
6646        let (sender, handle) = mock_packet_planning(transport);
6647        mgr.start(sender.clone(), new_session_tx, None, None)?;
6648        assert!(mgr.is_started());
6649
6650        let pseudonym = HoprPseudonym::random();
6651
6652        // The setup fails after the slot is inserted (notifying about the new
6653        // incoming session errors out because the receiver is gone), so the slot
6654        // must be rolled back instead of lingering until idle eviction.
6655        let result = mgr
6656            .handle_incoming_session_initiation(
6657                pseudonym,
6658                StartInitiation {
6659                    challenge: MIN_CHALLENGE,
6660                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6661                    capabilities: HoprSessionCapabilities::empty(),
6662                    additional_data: 0,
6663                },
6664            )
6665            .await;
6666        assert!(result.is_err());
6667
6668        // An empty active-session set proves the slot was removed and, since
6669        // sessions are keyed by pseudonym, that the pseudonym is free again.
6670        assert!(
6671            wait_for_no_active_sessions(&mgr).await,
6672            "the partially established session slot was not rolled back"
6673        );
6674
6675        // Cleanup
6676        sender.close_channel();
6677        handle.await??;
6678
6679        Ok(())
6680    }
6681
6682    /// Collects everything that arrives on `rx` during `window`, returning once it elapses.
6683    async fn originated_during(
6684        rx: &mut futures::channel::mpsc::UnboundedReceiver<(DestinationRouting, ApplicationDataOut)>,
6685        window: Duration,
6686    ) -> Vec<(DestinationRouting, ApplicationDataOut)> {
6687        let mut collected = Vec::new();
6688        let deadline = tokio::time::Instant::now() + window;
6689        while let Ok(Some(item)) = timeout(
6690            deadline.saturating_duration_since(tokio::time::Instant::now()),
6691            rx.next(),
6692        )
6693        .await
6694        {
6695            collected.push(item);
6696        }
6697        collected
6698    }
6699
6700    /// The manager floors the keep-alive period at [`MIN_SURB_BUFFER_NOTIFICATION_PERIOD`], so this
6701    /// is as fast as an Exit keep-alive can be made to run, and it sets the pace of these tests.
6702    const KEEP_ALIVE_PERIOD: Duration = MIN_SURB_BUFFER_NOTIFICATION_PERIOD;
6703
6704    type RecordingManager = SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>;
6705    type Originated = futures::channel::mpsc::UnboundedReceiver<(DestinationRouting, ApplicationDataOut)>;
6706
6707    /// Brings up an Exit-side session and returns once its keep-alive stream is observably running.
6708    ///
6709    /// The "observably running" part is load-bearing for every caller: `nothing was originated` is
6710    /// equally true of a stream that stopped and one that never started, so a test that does not
6711    /// first establish the stream is alive proves nothing when it later sees silence.
6712    async fn exit_session_originating_keep_alives(
6713        cfg: SessionManagerConfig,
6714    ) -> anyhow::Result<(RecordingManager, Originated, HoprPseudonym)> {
6715        let mgr = RecordingManager::new(SessionManagerConfig {
6716            surb_balance_notify_period: Some(KEEP_ALIVE_PERIOD),
6717            ..cfg
6718        });
6719
6720        let (msg_tx, mut msg_rx) = futures::channel::mpsc::unbounded();
6721        // Held, not dropped: dropping it would fail the establishment notification instead.
6722        let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(4);
6723        mgr.start(msg_tx, new_session_tx, None, None)?;
6724
6725        let pseudonym = HoprPseudonym::random();
6726        mgr.handle_incoming_session_initiation(
6727            pseudonym,
6728            StartInitiation {
6729                challenge: MIN_CHALLENGE,
6730                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6731                // Empty capabilities keep rate control on, which is what spawns the balancer and
6732                // the keep-alive stream. `NoRateControl` would skip both and make this vacuous.
6733                capabilities: HoprSessionCapabilities(Capabilities::empty()),
6734                additional_data: 0,
6735            },
6736        )
6737        .await?;
6738
6739        let observed = originated_during(&mut msg_rx, KEEP_ALIVE_PERIOD * 2 + Duration::from_millis(500)).await;
6740        let keep_alives = observed
6741            .iter()
6742            .filter(|(routing, data)| {
6743                msg_type(data, StartProtocolDiscriminants::KeepAlive)
6744                    && matches!(routing, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if p == &pseudonym)
6745            })
6746            .count();
6747        anyhow::ensure!(
6748            keep_alives > 0,
6749            "no return-routed keep-alive was originated, so this test cannot tell a stopped stream from one that \
6750             never ran; {} message(s) were observed in total",
6751            observed.len()
6752        );
6753
6754        Ok((mgr, msg_rx, pseudonym))
6755    }
6756
6757    /// Asserts `mgr` originates nothing further for `pseudonym`.
6758    ///
6759    /// Packets already handed to the sender before the closure are in flight rather than newly
6760    /// originated, so they are drained first and only what appears afterwards counts.
6761    async fn assert_no_further_origination(rx: &mut Originated, closure: &str) {
6762        let _in_flight = originated_during(rx, Duration::from_millis(200)).await;
6763
6764        let window = KEEP_ALIVE_PERIOD * 3;
6765        let after = originated_during(rx, window).await;
6766        assert!(
6767            after.is_empty(),
6768            "an Exit session closed by {closure} originated {} further packet(s) over {window:?} — each one is \
6769             return-routed to a pseudonym whose SURBs are gone, and one such packet is enough to stall all \
6770             origination on the node",
6771            after.len()
6772        );
6773    }
6774
6775    /// An Exit session must originate nothing once it has been closed explicitly.
6776    ///
6777    /// The Exit's keep-alive stream is a `repeat_with` on a rate limiter: it produces a
6778    /// return-routed packet every period regardless of whether a SURB exists to carry it. That is
6779    /// fine while the initiator is present and replenishing, and it is the *supply* side of the
6780    /// `london-01` outage once the initiator is gone — every such packet is one the routing
6781    /// resolution stage can never resolve, and one unresolvable packet there stalls all origination
6782    /// on the node (see `hopr_transport::path::resolve`).
6783    ///
6784    /// Teardown is therefore the only thing standing between a departed initiator and an unbounded
6785    /// supply of unresolvable packets, which is why each closure path has to be shown to stop the
6786    /// stream rather than merely drop the slot.
6787    #[test_log::test(tokio::test)]
6788    async fn an_exit_session_should_originate_nothing_after_an_explicit_close() -> anyhow::Result<()> {
6789        // Default idle timeout (180 s), so eviction cannot confound what the explicit close proves.
6790        let (mgr, mut msg_rx, pseudonym) = exit_session_originating_keep_alives(Default::default()).await?;
6791
6792        assert!(mgr.close_session(&pseudonym), "the session must exist to be closed");
6793
6794        assert_no_further_origination(&mut msg_rx, "an explicit close").await;
6795        Ok(())
6796    }
6797
6798    /// An Exit session must originate nothing once it has been evicted for being idle.
6799    ///
6800    /// This is the path that matters most for a departed initiator: nobody closes that session, so
6801    /// idle eviction is what ends it, and eviction runs through a Moka listener rather than the
6802    /// explicit close path. A keep-alive stream that survives eviction would go on originating
6803    /// unresolvable return packets with no session left to account for them.
6804    #[test_log::test(tokio::test)]
6805    async fn an_exit_session_should_originate_nothing_after_idle_eviction() -> anyhow::Result<()> {
6806        let idle_timeout = KEEP_ALIVE_PERIOD * 3;
6807        let (mgr, mut msg_rx, _) = exit_session_originating_keep_alives(SessionManagerConfig {
6808            idle_timeout,
6809            ..Default::default()
6810        })
6811        .await?;
6812
6813        // Moka evicts lazily, so drive its maintenance rather than waiting for the manager's own
6814        // (jittered, multi-second) eviction tick: this makes the eviction prompt and deterministic.
6815        for _ in 0..50 {
6816            mgr.sessions.run_pending_tasks();
6817            if mgr.active_sessions().is_empty() {
6818                break;
6819            }
6820            tokio::time::sleep(idle_timeout / 10).await;
6821        }
6822        assert!(
6823            mgr.active_sessions().is_empty(),
6824            "the idle session was never evicted, so this test cannot say anything about eviction"
6825        );
6826
6827        assert_no_further_origination(&mut msg_rx, "idle eviction").await;
6828        Ok(())
6829    }
6830
6831    // ---------------------------------------------------------------
6832    // PIX fill — the Exit's own keep-alives
6833    // ---------------------------------------------------------------
6834
6835    /// A [`PixFillControl`] over a random pseudonym and an empty SURB estimate.
6836    ///
6837    /// `reserve` is the *effective* reserve, as [`effective_surb_reserve`] would have derived it —
6838    /// these tests are about what `admit` does with one, not about where it came from. Against an
6839    /// estimate that starts empty, any `reserve` above zero refuses fill outright, which is what makes
6840    /// admission a usable assertion that a packet was classified as the notification.
6841    fn fill_control(notify_period: Option<Duration>, reserve: u64) -> PixFillControl {
6842        PixFillControl::new(
6843            HoprPseudonym::random(),
6844            RateController::new(0, Duration::from_secs(1)),
6845            notify_period,
6846            reserve,
6847            Default::default(),
6848        )
6849    }
6850
6851    /// The `surb_starved` gauge moves on the edges, and a control dropped mid-stall returns it.
6852    ///
6853    /// Per refused packet would measure how hard the stream retried rather than how long the
6854    /// Session was starved, and a control that went away while withholding would leave the count
6855    /// standing for the life of the process.
6856    #[test]
6857    fn the_surb_starved_gauge_counts_stalls_and_is_returned_on_drop() {
6858        use crate::supervision::telemetry::probe;
6859
6860        probe::reset();
6861        let control = fill_control(None, 10);
6862        let now = Instant::now();
6863
6864        // The estimator starts empty, so every one of these is below the reserve of ten.
6865        for _ in 0..3 {
6866            assert!(!control.admit_at(now), "fill must be withheld below the reserve");
6867        }
6868        assert_eq!(
6869            1,
6870            probe::get("sessions_stalled/surb_starved"),
6871            "three refusals are one stall, not three"
6872        );
6873
6874        // Enough SURBs arrive to clear the reserve.
6875        control
6876            .estimator
6877            .produced
6878            .fetch_add(10, std::sync::atomic::Ordering::Relaxed);
6879        assert!(control.admit_at(now));
6880        assert_eq!(0, probe::get("sessions_stalled/surb_starved"));
6881
6882        // And a control dropped while withholding returns its own.
6883        control
6884            .estimator
6885            .consumed
6886            .fetch_add(10, std::sync::atomic::Ordering::Relaxed);
6887        assert!(!control.admit_at(now));
6888        assert_eq!(1, probe::get("sessions_stalled/surb_starved"));
6889        drop(control);
6890        assert_eq!(
6891            0,
6892            probe::get("sessions_stalled/surb_starved"),
6893            "a session that dies mid-stall must not leave the gauge standing"
6894        );
6895    }
6896
6897    /// The fill reserve reads the estimator's held count, which must not stay inflated by evicted
6898    /// SURBs. After an overflow drains, the real store is empty, yet a raw `produced - consumed` would
6899    /// still read far above the reserve and admit fill into an empty buffer -- the exact failure the
6900    /// reserve exists to prevent (a return packet that finds no SURB stalls every packet the node
6901    /// originates). With evictions counted, the held count is what the store holds, so fill is withheld.
6902    #[test]
6903    fn the_fill_reserve_is_not_defeated_by_a_post_overflow_estimate() {
6904        use std::sync::atomic::Ordering;
6905
6906        const RESERVE: u64 = 100;
6907        let control = fill_control(None, RESERVE);
6908        let now = Instant::now();
6909
6910        // Post-overflow-drain: 1000 SURBs arrived, the full store evicted 900 of them, and the 100 it
6911        // actually held were all spent. The store is empty; `produced - consumed` alone reads 900.
6912        control.estimator.produced.fetch_add(1000, Ordering::Relaxed);
6913        control.estimator.evicted.fetch_add(900, Ordering::Relaxed);
6914        control.estimator.consumed.fetch_add(100, Ordering::Relaxed);
6915
6916        assert_eq!(0, control.drainable_surbs(), "an empty store has nothing to drain");
6917        assert!(
6918            !control.admit_at(now),
6919            "fill must be withheld: the real held count is below the reserve"
6920        );
6921    }
6922
6923    /// One stream, two producers, and the faster of them wins.
6924    ///
6925    /// The SURB-level notification and PIX fill are the same packet sent for different reasons, so
6926    /// running two streams of them would double the Exit's return traffic to say the same thing
6927    /// twice. What has to be preserved is the floor: turning fill off must leave the notification
6928    /// running, and a Session with no notification configured must still be fillable — that second
6929    /// case is the deployment's own test fixtures, which pin `surb_balance_notify_period: None`.
6930    #[test]
6931    fn the_exit_keep_alive_rate_is_the_faster_of_its_two_producers() {
6932        let notify = Duration::from_secs(60);
6933        let control = fill_control(Some(notify), 1);
6934
6935        assert_eq!(
6936            FillRate::ZERO,
6937            control.effective_rate(),
6938            "nothing runs before the notification's initial delay has elapsed"
6939        );
6940
6941        control.start_notify();
6942        assert_eq!(FillRate::once_per(notify), control.effective_rate());
6943
6944        control.set_fill(FillRate::per_second(10));
6945        assert_eq!(
6946            FillRate::per_second(10),
6947            control.effective_rate(),
6948            "fill above the notification rate must take over"
6949        );
6950
6951        control.set_fill(FillRate::once_per(Duration::from_secs(600)));
6952        assert_eq!(
6953            FillRate::once_per(notify),
6954            control.effective_rate(),
6955            "fill below the notification rate must not slow it down"
6956        );
6957
6958        control.set_fill(FillRate::ZERO);
6959        assert_eq!(
6960            FillRate::once_per(notify),
6961            control.effective_rate(),
6962            "a Session that has stopped filling still reports its SURB level"
6963        );
6964
6965        // And the other way round: no notification configured, so fill is the only producer.
6966        let fill_only = fill_control(None, 1);
6967        fill_only.start_notify();
6968        assert_eq!(FillRate::ZERO, fill_only.effective_rate());
6969        fill_only.set_fill(FillRate::per_second(4));
6970        assert_eq!(FillRate::per_second(4), fill_only.effective_rate());
6971        fill_only.set_fill(FillRate::ZERO);
6972        assert_eq!(FillRate::ZERO, fill_only.effective_rate());
6973    }
6974
6975    /// Fill yields to the Session's own SURB reserve; the SURB-level notification does not.
6976    ///
6977    /// The asymmetry is the whole rule. A return packet that finds no SURB is not dropped — the
6978    /// routing resolver holds it, and every packet this node originates behind it, for the whole of
6979    /// `surb_resolution_wait`. So an Exit that filled a cycle down to its last SURB would stall the
6980    /// Session it was filling for, and every other Session on the node with it. The notification is
6981    /// exempt because it is the message that *asks* for more SURBs: refusing to send it when SURBs
6982    /// are scarce is refusing to ask for the thing that is scarce.
6983    #[test]
6984    fn fill_withholds_below_the_surb_reserve_while_the_level_notification_does_not() {
6985        const RESERVE: u64 = 100;
6986        let control = fill_control(Some(Duration::from_secs(3600)), RESERVE);
6987        control.start_notify();
6988
6989        // The first packet is the notification, whatever the estimate says.
6990        assert!(control.admit(), "the level notification must go out on an empty buffer");
6991        // The notification period is an hour, so everything after it is fill — and there is nothing
6992        // to spend.
6993        assert!(!control.admit(), "fill must not spend a SURB the Session does not have");
6994
6995        control
6996            .estimator
6997            .produced
6998            .fetch_add(RESERVE - 1, std::sync::atomic::Ordering::Relaxed);
6999        assert!(!control.admit(), "one short of the reserve is still short");
7000
7001        control
7002            .estimator
7003            .produced
7004            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7005        assert!(control.admit(), "at the reserve, fill may spend");
7006
7007        // Consumption counted elsewhere walks the estimate back below the reserve.
7008        control
7009            .estimator
7010            .consumed
7011            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7012        assert!(!control.admit());
7013    }
7014
7015    /// Exactly one caller may take a Session into its drain, and doing so silences the notification.
7016    ///
7017    /// `begin_surb_drain` is reached from the operator's close, the closure notifier and
7018    /// `handle_session_error` independently, so two closes can both read [`is_draining`] as false and
7019    /// race for the same drain. The transition is what settles it: the winner is the one caller that
7020    /// tells the supervisor the Session closed, and the loser is answered as though it had arrived
7021    /// afterwards. Were the check and the write separate, both would start a drain and the supervisor
7022    /// would be told twice about one close.
7023    ///
7024    /// [`is_draining`]: PixFillControl::is_draining
7025    #[test]
7026    fn only_one_caller_can_take_a_session_into_its_drain() {
7027        let notify = Duration::from_secs(60);
7028        let control = fill_control(Some(notify), 1);
7029        control.start_notify();
7030        assert_eq!(FillRate::once_per(notify), control.effective_rate());
7031
7032        assert!(control.enter_drain(), "the first caller takes the drain");
7033        assert!(control.is_draining());
7034        assert!(
7035            !control.enter_drain(),
7036            "a concurrent close must lose the transition rather than start a second drain"
7037        );
7038        assert!(control.is_draining(), "losing the transition must not clear the drain");
7039
7040        assert_eq!(
7041            FillRate::ZERO,
7042            control.effective_rate(),
7043            "a draining stream runs at the fill rate alone, and nothing is filling it"
7044        );
7045        assert!(
7046            !control.admit(),
7047            "the notification is the one packet exempt from the reserve, and a drain silences it"
7048        );
7049
7050        // Nor can the one-shot delay task, which holds a clone of this control, turn it back on.
7051        control.start_notify();
7052        assert_eq!(FillRate::ZERO, control.effective_rate());
7053    }
7054
7055    /// The SURB-level notification is classified by when it was *due*, not by when the scheduler got
7056    /// round to releasing it.
7057    ///
7058    /// The rate limiter that releases these packets does not hit the period exactly: it sleeps in
7059    /// chunks of `MAX_WAIT_CHUNK`, re-reads its controller on each wake, and rounds a rate into an
7060    /// integer packet count. Compared against the period exactly, a period the controller rounds
7061    /// short by a microsecond would classify every other notification as fill — and, below the
7062    /// reserve, drop it. That is the message that *asks* for the SURBs whose absence caused the drop,
7063    /// and it runs on every Exit Session with a notification period configured, PIX or not.
7064    ///
7065    /// The reserve here is the instrument: the estimator is empty, so a packet the rule classifies as
7066    /// fill is refused and a packet it classifies as the notification is admitted. Admission is
7067    /// therefore the assertion that it was not counted as fill.
7068    #[test]
7069    fn a_level_notification_released_a_shade_early_is_still_the_notification() {
7070        const RESERVE: u64 = 100;
7071        // Two seconds, so the slack is `MAX_WAIT_CHUNK` rather than half the period, and the two
7072        // bounds are distinguishable.
7073        let period = Duration::from_secs(2);
7074        let control = fill_control(Some(period), RESERVE);
7075        control.start_notify();
7076
7077        let t0 = Instant::now();
7078        assert!(
7079            control.admit_at(t0),
7080            "the first packet is the notification, whatever the estimate says"
7081        );
7082
7083        // A shade under the period — the shape a rate rounded down by a microsecond produces.
7084        let early = t0 + period - Duration::from_micros(1);
7085        assert!(
7086            control.admit_at(early),
7087            "a notification released a microsecond early must not be reclassified as fill and dropped"
7088        );
7089
7090        // And it is the notification rather than a free pass: the next packet is measured from when
7091        // this one actually went out, so everything inside the following period is fill.
7092        assert!(
7093            !control.admit_at(early + Duration::from_millis(1)),
7094            "the packet after the notification is fill, and there is nothing to spend"
7095        );
7096        assert!(
7097            !control.admit_at(early + period - MAX_WAIT_CHUNK - Duration::from_millis(1)),
7098            "a packet inside the slack window is still fill"
7099        );
7100
7101        // The slack window opens `MAX_WAIT_CHUNK` before the period is up, and not earlier.
7102        assert!(
7103            control.admit_at(early + period - MAX_WAIT_CHUNK),
7104            "at the edge of the slack window the packet is the notification again"
7105        );
7106
7107        // A period short enough that `MAX_WAIT_CHUNK` would swallow it keeps half of itself.
7108        let short = Duration::from_millis(200);
7109        let control = fill_control(Some(short), RESERVE);
7110        control.start_notify();
7111        let t0 = Instant::now();
7112        assert!(control.admit_at(t0));
7113        assert!(
7114            !control.admit_at(t0 + short / 2 - Duration::from_millis(1)),
7115            "the slack is capped at half the period, so it cannot widen a short period away to nothing"
7116        );
7117        assert!(control.admit_at(t0 + short / 2));
7118    }
7119
7120    /// The reserve is a ceiling on a figure derived from the buffer the *Entry* asked for.
7121    ///
7122    /// The configured value is an absolute SURB count, while the buffer it is compared against is
7123    /// sized by the peer. Left absolute, a PIX Session whose Entry targets fewer SURBs than the
7124    /// reserve could never be filled at all — every fill packet withheld, the funded cycle stranded on
7125    /// `max_recovery_time`, and nothing in the logs but a backoff.
7126    #[test]
7127    fn the_fill_surb_reserve_is_capped_at_a_quarter_of_the_entrys_announced_buffer() {
7128        // The shipped reserve against the worked profile's buffer: the configured value binds and the
7129        // derivation is inert, which is what keeps this from quietly weakening production.
7130        assert_eq!(500, effective_surb_reserve(500, 7_000));
7131        // Exactly four times the reserve is the point at which the two agree.
7132        assert_eq!(500, effective_surb_reserve(500, 2_000));
7133
7134        // Below that, the Entry's buffer is what decides. The cluster fixtures' 64-SURB target is the
7135        // case this exists for.
7136        assert_eq!(16, effective_surb_reserve(500, 64));
7137        assert_eq!(12, effective_surb_reserve(500, 50));
7138
7139        // Never zero: an Entry announcing a buffer of three would otherwise let fill spend the
7140        // Session's last SURB, which is the one failure the reserve exists to prevent.
7141        assert_eq!(1, effective_surb_reserve(500, 3));
7142        assert_eq!(1, effective_surb_reserve(500, 0));
7143        assert_eq!(1, effective_surb_reserve(0, 7_000));
7144    }
7145
7146    /// Test dimensions whose cycle is large enough to need a visible fill rate.
7147    ///
7148    /// [`small_pix_params`] is six packets, which any deadline covers at the heartbeat; these are
7149    /// three hundred, which against the fixture's 40 s recovery budget needs about eleven packets a
7150    /// second — far enough above the one-per-second notification for the two to be told apart.
7151    fn fill_pix_params() -> PixParams {
7152        PixParams::try_new(100, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE).expect("test dimensions must be valid")
7153    }
7154
7155    /// Brings up an Exit-side PIX Session, funds its first cycle, and returns once it is recovering.
7156    ///
7157    /// The Entry half is not simulated: the supervisor is a state machine, and driving it through
7158    /// `SsaRequestSent`, `CommitmentVerified` and `DepositConfirmed` puts its cycle in exactly the
7159    /// state a real commitment and deposit would — which is the state fill is planned from. What is
7160    /// real here is everything downstream of that: the action driver, the rate controller, the
7161    /// admission predicate and the stream.
7162    async fn recovering_exit_pix_session(
7163        capabilities: Capabilities,
7164        notify_period: Option<Duration>,
7165        fill: crate::supervision::PixFillConfig,
7166    ) -> anyhow::Result<(RecordingManager, Originated, HoprPseudonym)> {
7167        let params = fill_pix_params();
7168        let mgr = RecordingManager::new(SessionManagerConfig {
7169            surb_balance_notify_period: notify_period,
7170            pix_config: IncomingSessionPixConfig {
7171                quota_range: 0..=pix_params_to_quota(&params),
7172                supervision: SupervisorConfig {
7173                    // As short as a legal pair gets: the idle deadline must clear the reconstructor's
7174                    // 30 s acknowledgement window, and the hard deadline must exceed the idle one.
7175                    // That is what makes the required fill rate — a whole cycle over 75 % of 40 s,
7176                    // about eleven packets a second — observable inside a test-length window.
7177                    max_recovery_idle: Duration::from_secs(31),
7178                    max_recovery_time: Duration::from_secs(40),
7179                    fill,
7180                    ..Default::default()
7181                },
7182                ..Default::default()
7183            },
7184            ..Default::default()
7185        });
7186
7187        let (msg_tx, msg_rx) = futures::channel::mpsc::unbounded();
7188        let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(4);
7189        mgr.start(
7190            msg_tx,
7191            new_session_tx,
7192            Some(pix_toolbox_with_pool(
7193                Arc::new(SsaShareGenerator::new(SsaGeneratorConfig {
7194                    polynomials_per_ssa: params.polys_per_ssa(),
7195                    threshold: params.shares_per_poly(),
7196                    surplus_shares: params.surplus_shares(),
7197                })),
7198                SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
7199            )),
7200            None,
7201        )?;
7202
7203        let pseudonym = HoprPseudonym::random();
7204        mgr.handle_incoming_session_initiation(
7205            pseudonym,
7206            StartInitiation {
7207                challenge: MIN_CHALLENGE,
7208                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
7209                capabilities: HoprSessionCapabilities(capabilities | Capability::UsePIX),
7210                additional_data: into_initiation_additional_data(params.into_additional_data(0)),
7211            },
7212        )
7213        .await?;
7214
7215        let slot = mgr.sessions.get(&pseudonym).context("the session slot must exist")?;
7216        let supervisor = slot
7217            .pix_supervisor
7218            .get()
7219            .context("an Exit-side PIX session must have a supervisor")?;
7220
7221        let ssa_id = SsaId::new(pseudonym, SsaIndex::new(1).expect("index one is non-zero"));
7222        for event in [
7223            SessionPixEvent::SsaRequestSent(ssa_id),
7224            SessionPixEvent::CommitmentVerified(ssa_id),
7225            SessionPixEvent::DepositConfirmed {
7226                ssa_id,
7227                amount: hopr_api::HoprBalance::new_base(1000),
7228            },
7229        ] {
7230            supervisor
7231                .send_event(event)
7232                .await
7233                .map_err(|()| anyhow::anyhow!("the supervisor stopped accepting events"))?;
7234        }
7235
7236        Ok((mgr, msg_rx, pseudonym))
7237    }
7238
7239    /// Counts the return-routed keep-alives `mgr` originated for `pseudonym` during `window`.
7240    async fn keep_alives_during(rx: &mut Originated, pseudonym: HoprPseudonym, window: Duration) -> usize {
7241        originated_during(rx, window)
7242            .await
7243            .iter()
7244            .filter(|(routing, data)| {
7245                msg_type(data, StartProtocolDiscriminants::KeepAlive)
7246                    && matches!(routing, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if *p == pseudonym)
7247            })
7248            .count()
7249    }
7250
7251    /// A funded, idle PIX Session must be filled by the Exit at the rate its own deadline needs.
7252    ///
7253    /// This is the mechanism end to end on one node: the supervisor plans a rate, the action driver
7254    /// applies it to the shared keep-alive controller, the admission predicate lets the packets past,
7255    /// and they come out return-routed to the Entry. The floor asserted is deliberately the
7256    /// *notification* rate rather than the planned one — a regression that leaves the stream running
7257    /// at one packet a second is exactly the failure this exists to prevent, and it looks like
7258    /// success to any assertion that only checks the stream is alive.
7259    #[test_log::test(tokio::test)]
7260    async fn a_funded_idle_pix_session_is_filled_by_the_exit() -> anyhow::Result<()> {
7261        let notify = Duration::from_secs(1);
7262        let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7263            Capabilities::empty(),
7264            Some(notify),
7265            // Draining off, so the trailing close below still means "the stream stopped at once".
7266            // What a drain does instead is the subject of the tests further down.
7267            crate::supervision::PixFillConfig {
7268                min_surb_reserve: 1,
7269                drain_after_close: false,
7270                ..Default::default()
7271            },
7272        )
7273        .await?;
7274
7275        // SURBs to spend, so the reserve is not what this test is measuring.
7276        mgr.sessions
7277            .get(&pseudonym)
7278            .context("the session slot must exist")?
7279            .surb_estimator
7280            .produced
7281            .fetch_add(100_000, std::sync::atomic::Ordering::Relaxed);
7282
7283        // One sampling interval for the planner, then a window long enough that the notification
7284        // alone could not account for what is counted.
7285        let _ramp = originated_during(&mut msg_rx, Duration::from_millis(1500)).await;
7286        let window = Duration::from_millis(1500);
7287        let observed = keep_alives_during(&mut msg_rx, pseudonym, window).await;
7288
7289        // Counted against the rate the notification is *promoted* at rather than against the period,
7290        // for the reason `admit_at` documents: the classification window is one slack shorter than the
7291        // period, so a notification-only stream produces rather more than `window / period` packets.
7292        // Taking the period at face value here would set the floor too low and let a stream that had
7293        // stopped filling pass.
7294        let promote_every = notify - MAX_WAIT_CHUNK.min(notify / 2);
7295        let notify_only = (window.as_secs_f64() / promote_every.as_secs_f64()).ceil() as usize;
7296        assert!(
7297            observed > notify_only,
7298            "a funded idle cycle produced {observed} keep-alive(s) over {window:?}, which the {notify:?} SURB-level \
7299             notification alone could have produced ({notify_only}, one every {promote_every:?})"
7300        );
7301
7302        assert!(mgr.close_session(&pseudonym), "the session must exist to be closed");
7303        assert_no_further_origination(&mut msg_rx, "an explicit close of a filling PIX session").await;
7304        Ok(())
7305    }
7306
7307    // ---------------------------------------------------------------
7308    // Draining a closed PIX Session
7309    // ---------------------------------------------------------------
7310
7311    /// A funded, filling Exit PIX Session whose SURB estimate covers the rest of its cycle.
7312    ///
7313    /// The 100 000 is not decoration: it is the estimate `begin_surb_drain` nets the reserve out of
7314    /// and hands the supervisor, and against [`fill_pix_params`]' 300-packet cycle it is what makes a
7315    /// drain admissible at all. The ramp is waited out for the reason
7316    /// [`exit_session_originating_keep_alives`] gives — silence after a close says nothing unless the
7317    /// stream was observably running before it.
7318    async fn drainable_exit_pix_session(
7319        notify_period: Duration,
7320    ) -> anyhow::Result<(RecordingManager, Originated, HoprPseudonym)> {
7321        let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7322            Capabilities::empty(),
7323            Some(notify_period),
7324            crate::supervision::PixFillConfig {
7325                min_surb_reserve: 1,
7326                ..Default::default()
7327            },
7328        )
7329        .await?;
7330
7331        mgr.sessions
7332            .get(&pseudonym)
7333            .context("the session slot must exist")?
7334            .surb_estimator
7335            .produced
7336            .fetch_add(100_000, std::sync::atomic::Ordering::Relaxed);
7337
7338        let observed = keep_alives_during(&mut msg_rx, pseudonym, Duration::from_millis(1500)).await;
7339        anyhow::ensure!(
7340            observed > 0,
7341            "the fixture's keep-alive stream never ran, so nothing below can tell a drain from a Session that was \
7342             silent all along"
7343        );
7344        Ok((mgr, msg_rx, pseudonym))
7345    }
7346
7347    /// A closed PIX Session goes on originating until the cycle it was paid for recovers.
7348    ///
7349    /// The deposit is stranded rather than refunded if the cycle does not complete, so a close that
7350    /// tore the stream down while the Exit still held enough SURBs to finish would throw away money
7351    /// both sides have already paid. What must *not* happen is the mirror image of that: origination
7352    /// continuing past the point it is buying anything. So the recovery is delivered and the slot has
7353    /// to be gone, and the stream silent, on the other side of it.
7354    #[test_log::test(tokio::test)]
7355    async fn a_closed_pix_session_keeps_originating_until_its_cycle_recovers() -> anyhow::Result<()> {
7356        let notify = Duration::from_secs(1);
7357        let (mgr, mut msg_rx, pseudonym) = drainable_exit_pix_session(notify).await?;
7358
7359        assert!(
7360            mgr.close_session(&pseudonym),
7361            "a close that starts a drain is still a close that was handled"
7362        );
7363        assert!(
7364            mgr.active_sessions().contains(&pseudonym),
7365            "the slot must survive the close — the drain runs out of it, and the supervisor's own teardown is what \
7366             removes it"
7367        );
7368
7369        // The SURB-level notification is switched off for the whole drain, so every packet counted
7370        // here is fill. The bar is nevertheless the notification-only figure, which keeps this
7371        // comparable with `a_funded_idle_pix_session_is_filled_by_the_exit` above.
7372        let window = Duration::from_millis(1500);
7373        let observed = keep_alives_during(&mut msg_rx, pseudonym, window).await;
7374        let promote_every = notify - MAX_WAIT_CHUNK.min(notify / 2);
7375        let notify_only = (window.as_secs_f64() / promote_every.as_secs_f64()).ceil() as usize;
7376        assert!(
7377            observed > notify_only,
7378            "a closed session with a full SURB buffer originated {observed} keep-alive(s) over {window:?}, which is \
7379             no more than the {notify_only} its (now silenced) notification would have accounted for"
7380        );
7381
7382        // Recovery is the end of the drain: there is nothing left to work for, so the Session must go.
7383        let ssa_id = SsaId::new(pseudonym, SsaIndex::new(1).expect("index one is non-zero"));
7384        mgr.dispatch_pix_event(HoprSessionInPixEvent::SsaRecovered(ssa_id))
7385            .await?;
7386        assert!(
7387            wait_for_no_active_sessions(&mgr).await,
7388            "a recovered cycle must end the drain and tear the Session down"
7389        );
7390        assert_no_further_origination(&mut msg_rx, "a completed SURB drain").await;
7391        Ok(())
7392    }
7393
7394    /// A closed PIX Session with too few SURBs to finish is torn down as it always was.
7395    ///
7396    /// A hundred SURBs against [`fill_pix_params`]' 300-packet cycle: enough for the manager to offer
7397    /// the close to the supervisor, and not enough for the supervisor to take it. That is the
7398    /// arithmetic under test — draining on a buffer that runs out partway is strictly worse than not
7399    /// draining at all, since the SURBs are spent down to the reserve, the cycle recovers nothing,
7400    /// and every packet past the last useful one is return-routed on behalf of a Session that is gone.
7401    #[test_log::test(tokio::test)]
7402    async fn a_closed_pix_session_without_enough_surbs_originates_nothing() -> anyhow::Result<()> {
7403        let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7404            Capabilities::empty(),
7405            Some(MIN_SURB_BUFFER_NOTIFICATION_PERIOD),
7406            crate::supervision::PixFillConfig {
7407                min_surb_reserve: 1,
7408                ..Default::default()
7409            },
7410        )
7411        .await?;
7412
7413        mgr.sessions
7414            .get(&pseudonym)
7415            .context("the session slot must exist")?
7416            .surb_estimator
7417            .produced
7418            .fetch_add(100, std::sync::atomic::Ordering::Relaxed);
7419        anyhow::ensure!(
7420            keep_alives_during(&mut msg_rx, pseudonym, Duration::from_millis(1500)).await > 0,
7421            "the stream must be running before the close for its absence afterwards to mean anything"
7422        );
7423
7424        assert!(mgr.close_session(&pseudonym), "the session must exist to be closed");
7425        assert!(
7426            wait_for_no_active_sessions(&mgr).await,
7427            "a drain the supervisor refuses must still tear the Session down"
7428        );
7429        assert_no_further_origination(&mut msg_rx, "a close with too few SURBs to drain").await;
7430        Ok(())
7431    }
7432
7433    /// With `drain_after_close` off, every close path is what it was before draining existed.
7434    ///
7435    /// Including the part an operator would notice first: the slot is gone by the time
7436    /// `close_session` returns, rather than one channel hop later once the supervisor has answered.
7437    #[test_log::test(tokio::test)]
7438    async fn a_closed_pix_session_with_draining_disabled_originates_nothing() -> anyhow::Result<()> {
7439        let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7440            Capabilities::empty(),
7441            Some(Duration::from_secs(1)),
7442            crate::supervision::PixFillConfig {
7443                min_surb_reserve: 1,
7444                drain_after_close: false,
7445                ..Default::default()
7446            },
7447        )
7448        .await?;
7449
7450        mgr.sessions
7451            .get(&pseudonym)
7452            .context("the session slot must exist")?
7453            .surb_estimator
7454            .produced
7455            .fetch_add(100_000, std::sync::atomic::Ordering::Relaxed);
7456        anyhow::ensure!(
7457            keep_alives_during(&mut msg_rx, pseudonym, Duration::from_millis(1500)).await > 0,
7458            "the fixture's keep-alive stream never ran"
7459        );
7460
7461        assert!(mgr.close_session(&pseudonym), "the session must exist to be closed");
7462        assert!(
7463            mgr.active_sessions().is_empty(),
7464            "with draining disabled the slot must be gone by the time the close returns"
7465        );
7466        assert_no_further_origination(&mut msg_rx, "an explicit close with draining disabled").await;
7467        Ok(())
7468    }
7469
7470    /// Closing a draining Session a second time forces the teardown.
7471    ///
7472    /// The drain is the Exit's decision, taken on the operator's behalf, and this is the operator's
7473    /// way to overrule it: a second `SessionManager::close_session` — or a peer `SessionError`, which
7474    /// arrives on the same path — stops the origination immediately rather than waiting out a cycle.
7475    /// The end-of-stream a close of our own ingress produces is deliberately *not* on this path; it
7476    /// carries `WriteClosed`/`EmptyRead` and is absorbed.
7477    #[test_log::test(tokio::test)]
7478    async fn a_second_close_of_a_draining_session_tears_it_down() -> anyhow::Result<()> {
7479        let (mgr, mut msg_rx, pseudonym) = drainable_exit_pix_session(Duration::from_secs(1)).await?;
7480
7481        assert!(mgr.close_session(&pseudonym), "the first close starts the drain");
7482        assert!(
7483            mgr.active_sessions().contains(&pseudonym),
7484            "the drain must be running for the second close to have anything to overrule"
7485        );
7486
7487        assert!(mgr.close_session(&pseudonym), "the second close must find the session");
7488        assert!(
7489            mgr.active_sessions().is_empty(),
7490            "a second explicit close must remove the slot rather than let the drain run on"
7491        );
7492        assert_no_further_origination(&mut msg_rx, "a second explicit close of a draining session").await;
7493        Ok(())
7494    }
7495
7496    /// The same Session, with no SURBs to spend, must fall back to its SURB-level notification.
7497    ///
7498    /// A Session whose Entry has stopped supplying SURBs is the one case where filling harder is
7499    /// actively harmful: the packets cannot be routed, and an unroutable origination holds up every
7500    /// packet the node sends. What must survive is the notification, because it is the message that
7501    /// asks for the SURBs whose absence caused the backoff.
7502    #[test_log::test(tokio::test)]
7503    async fn fill_backs_off_to_the_notification_when_the_entry_supplies_no_surbs() -> anyhow::Result<()> {
7504        // At `MIN_SURB_BUFFER_NOTIFICATION_PERIOD`, so it survives the clamp `SessionManager::new`
7505        // applies. A shorter value would be raised to this one anyway, and the bound below would then
7506        // be computed from a period that is not the one in effect.
7507        let notify = MIN_SURB_BUFFER_NOTIFICATION_PERIOD;
7508        let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7509            Capabilities::empty(),
7510            Some(notify),
7511            crate::supervision::PixFillConfig {
7512                min_surb_reserve: 500,
7513                ..Default::default()
7514            },
7515        )
7516        .await?;
7517        assert_eq!(
7518            Some(notify),
7519            mgr.cfg.surb_balance_notify_period,
7520            "the fixture's notification period must be the one the manager actually runs"
7521        );
7522
7523        let _ramp = originated_during(&mut msg_rx, Duration::from_millis(1500)).await;
7524        let window = Duration::from_millis(1500);
7525        let observed = keep_alives_during(&mut msg_rx, pseudonym, window).await;
7526
7527        // A notification is promoted every `period - min(MAX_WAIT_CHUNK, period / 2)`, not every
7528        // period: `admit_at` opens the window a slack early so the classification does not rest on the
7529        // rate limiter's scheduling latency. The ceiling has to be computed from that shorter interval,
7530        // or a stream doing exactly what this test permits would be read as filling.
7531        let promote_every = notify - MAX_WAIT_CHUNK.min(notify / 2);
7532        let notify_only = (window.as_secs_f64() / promote_every.as_secs_f64()).ceil() as usize;
7533        assert!(
7534            observed > 0,
7535            "the SURB-level notification must survive the backoff — it is what asks for the SURBs"
7536        );
7537        assert!(
7538            observed <= notify_only + 1,
7539            "with no SURBs to spend the Exit originated {observed} keep-alive(s) over {window:?}, against the \
7540             {notify_only} its notification alone accounts for at one every {promote_every:?}"
7541        );
7542
7543        assert!(mgr.close_session(&pseudonym));
7544        Ok(())
7545    }
7546
7547    /// A `NoRateControl` PIX Session must still have a SURB level estimate, or its reserve is a lie.
7548    ///
7549    /// That branch runs no balancer, and until now it wired no estimator either: `produced` and
7550    /// `consumed` both stayed at zero for the life of the Session. A reserve measured against that
7551    /// counter would refuse every fill packet forever — which is the branch that can drain SURBs
7552    /// fastest, so it is the one where getting it wrong costs most.
7553    #[test_log::test(tokio::test)]
7554    async fn a_no_rate_control_pix_session_still_estimates_its_surb_level() -> anyhow::Result<()> {
7555        let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7556            Capability::NoRateControl.into(),
7557            Some(Duration::from_secs(1)),
7558            crate::supervision::PixFillConfig {
7559                min_surb_reserve: 1,
7560                ..Default::default()
7561            },
7562        )
7563        .await?;
7564
7565        let slot = mgr.sessions.get(&pseudonym).context("the session slot must exist")?;
7566        slot.surb_estimator
7567            .produced
7568            .fetch_add(100_000, std::sync::atomic::Ordering::Relaxed);
7569
7570        let _ = keep_alives_during(&mut msg_rx, pseudonym, Duration::from_millis(1500)).await;
7571
7572        assert!(
7573            slot.surb_estimator.consumed.load(std::sync::atomic::Ordering::Relaxed) > 0,
7574            "a NoRateControl Session that has sent keep-alives must have counted them as consumed SURBs"
7575        );
7576
7577        assert!(mgr.close_session(&pseudonym));
7578        Ok(())
7579    }
7580
7581    /// Verifies that established sessions exchange `KeepAlive` messages driven by the SURB balancer,
7582    /// that config updates propagate via keep-alives, and that SURB usage statistics are collected.
7583    ///
7584    /// ## Steps
7585    /// 1. Alice's manager is started with no `PixToolbox` and a `SurbBalancerConfig` with `target_surb_buffer_size:
7586    ///    10`. Bob's manager is configured with a 500ms `surb_balance_notify_period`.
7587    /// 2. Alice initiates a session with the balancer config and PIX quota set; the `StartSession` /
7588    ///    `SessionEstablished` handshake completes via mock transports.
7589    /// 3. Both managers report the same `target_surb_buffer_size` via `get_surb_balancer_config` (confirmed from both
7590    ///    Alice and Bob's perspective).
7591    /// 4. A 1500ms sleep allows the SURB balancer's periodic keep-alive timer to fire multiple times.
7592    /// 5. `update_surb_balancer_config` is called to raise the target to 50. After another 1500ms, Bob's manager
7593    ///    reflects the updated target via `get_surb_balancer_config`, confirming keep-alives communicated the change.
7594    /// 6. `get_surb_level_estimates` is called on both sides; both report positive sent/received/used counts,
7595    ///    confirming the balancer collected SURB statistics.
7596    /// 7. Alice closes the session; `ping_session` returns `NonExistingSession` after a short wait.
7597    #[test_log::test(tokio::test)]
7598    async fn session_manager_should_send_keep_alives_via_surb_balancer() -> anyhow::Result<()> {
7599        let alice_pseudonym = HoprPseudonym::random();
7600        let bob_peer: Address = (&ChainKeypair::random()).into();
7601
7602        let bob_cfg = SessionManagerConfig {
7603            surb_balance_notify_period: Some(Duration::from_millis(500)),
7604            ..Default::default()
7605        };
7606        let alice_mgr = SessionManager::new(Default::default());
7607        let bob_mgr = SessionManager::new(bob_cfg.clone());
7608
7609        let mut alice_transport = MockMsgSender::new();
7610        let mut bob_transport = MockMsgSender::new();
7611
7612        // Alice sends the StartSession message
7613        let mut open_sequence = mockall::Sequence::new();
7614        let bob_mgr_clone = bob_mgr.clone();
7615        alice_transport
7616            .expect_send_message()
7617            .once()
7618            .in_sequence(&mut open_sequence)
7619            .withf(move |peer, data| {
7620                msg_type(data, StartProtocolDiscriminants::StartSession)
7621                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
7622            })
7623            .returning(move |_, data| {
7624                let bob_mgr_clone = bob_mgr_clone.clone();
7625                Box::pin(async move {
7626                    bob_mgr_clone
7627                        .dispatch_message(
7628                            alice_pseudonym,
7629                            ApplicationDataIn {
7630                                data: data.data,
7631                                packet_info: Default::default(),
7632                            },
7633                        )
7634                        ?;
7635                    Ok(())
7636                })
7637            });
7638
7639        // Bob sends the SessionEstablished message
7640        let alice_mgr_clone = alice_mgr.clone();
7641        bob_transport
7642            .expect_send_message()
7643            .once()
7644            .in_sequence(&mut open_sequence)
7645            .withf(move |peer, data| {
7646                msg_type(data, StartProtocolDiscriminants::SessionEstablished)
7647                    && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if *p == alice_pseudonym)
7648            })
7649            .returning(move |_, data| {
7650                let alice_mgr_clone = alice_mgr_clone.clone();
7651                Box::pin(async move {
7652                    alice_mgr_clone.dispatch_message(
7653                        alice_pseudonym,
7654                        ApplicationDataIn {
7655                            data: data.data,
7656                            packet_info: Default::default(),
7657                        },
7658                    )?;
7659                    Ok(())
7660                })
7661            });
7662
7663        const INITIAL_BALANCER_TARGET: u64 = 10;
7664
7665        // Alice sends the KeepAlive messages reporting the initial balancer target
7666        let bob_mgr_clone = bob_mgr.clone();
7667        alice_transport
7668            .expect_send_message()
7669            .times(1..)
7670            //.in_sequence(&mut sequence)
7671            .withf(move |peer, data| {
7672                start_msg_match(data, |msg| matches!(msg, StartProtocol::KeepAlive(ka) if ka.flags.contains(KeepAliveFlag::BalancerTarget) && ka.additional_data == INITIAL_BALANCER_TARGET))
7673                //msg_type(data, StartProtocolDiscriminants::KeepAlive)
7674                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
7675            })
7676            .returning(move |_, data| {
7677                let bob_mgr_clone = bob_mgr_clone.clone();
7678                Box::pin(async move {
7679                    bob_mgr_clone
7680                        .dispatch_message(
7681                            alice_pseudonym,
7682                            ApplicationDataIn {
7683                                data: data.data,
7684                                packet_info: Default::default(),
7685                            },
7686                        )
7687                        ?;
7688                    Ok(())
7689                })
7690            });
7691
7692        const NEXT_BALANCER_TARGET: u64 = 50;
7693
7694        // Alice sends also the KeepAlive messages reporting the updated balancer target
7695        let bob_mgr_clone = bob_mgr.clone();
7696        alice_transport
7697            .expect_send_message()
7698            .times(1..)
7699            //.in_sequence(&mut sequence)
7700            .withf(move |peer, data| {
7701                start_msg_match(data, |msg| matches!(msg, StartProtocol::KeepAlive(ka) if ka.flags.contains(KeepAliveFlag::BalancerTarget) && ka.additional_data == NEXT_BALANCER_TARGET))
7702                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
7703            })
7704            .returning(move |_, data| {
7705                let bob_mgr_clone = bob_mgr_clone.clone();
7706                Box::pin(async move {
7707                    bob_mgr_clone
7708                        .dispatch_message(
7709                            alice_pseudonym,
7710                            ApplicationDataIn {
7711                                data: data.data,
7712                                packet_info: Default::default(),
7713                            },
7714                        )
7715                        ?;
7716                    Ok(())
7717                })
7718            });
7719
7720        // Bob sends at least 1 Keep Alive back reporting its SURB estimate
7721        let alice_mgr_clone = alice_mgr.clone();
7722        bob_transport
7723            .expect_send_message()
7724            .times(1..)
7725            //.in_sequence(&mut open_sequence)
7726            .withf(move |peer, data| {
7727                start_msg_match(data, |msg| matches!(msg, StartProtocol::KeepAlive(ka) if ka.flags.contains(KeepAliveFlag::BalancerState) && ka.additional_data > 0))
7728                    && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if *p == alice_pseudonym)
7729            })
7730            .returning(move |_, data| {
7731                let alice_mgr_clone = alice_mgr_clone.clone();
7732                Box::pin(async move {
7733                    alice_mgr_clone
7734                        .dispatch_message(
7735                            alice_pseudonym,
7736                            ApplicationDataIn {
7737                                data: data.data,
7738                                packet_info: Default::default(),
7739                            },
7740                        )
7741                        ?;
7742                    Ok(())
7743                })
7744            });
7745
7746        // Alice sends the terminating segment to close the Session
7747        let bob_mgr_clone = bob_mgr.clone();
7748        alice_transport
7749            .expect_send_message()
7750            .once()
7751            //.in_sequence(&mut sequence)
7752            .withf(move |peer, data| {
7753                hopr_protocol_session::types::SessionMessage::<{ ApplicationData::PAYLOAD_SIZE }>::try_from(
7754                    data.data.plain_text.as_ref(),
7755                )
7756                .ok()
7757                .and_then(|m| m.try_as_segment())
7758                .map(|s| s.is_terminating())
7759                .unwrap_or(false)
7760                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
7761            })
7762            .returning(move |_, data| {
7763                let bob_mgr_clone = bob_mgr_clone.clone();
7764                Box::pin(async move {
7765                    bob_mgr_clone
7766                        .dispatch_message(
7767                            alice_pseudonym,
7768                            ApplicationDataIn {
7769                                data: data.data,
7770                                packet_info: Default::default(),
7771                            },
7772                        )
7773                        ?;
7774                    Ok(())
7775                })
7776            });
7777
7778        let mut ahs = Vec::new();
7779
7780        // Start Alice
7781        let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
7782        let (alice_sender, alice_handle) = mock_packet_planning(alice_transport);
7783        ahs.extend(alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?);
7784        assert!(alice_mgr.is_started());
7785
7786        // Start Bob
7787        let (new_session_tx_bob, new_session_rx_bob) = futures::channel::mpsc::channel(1024);
7788        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
7789        ahs.extend(bob_mgr.start(bob_sender.clone(), new_session_tx_bob, None, None)?);
7790        assert!(bob_mgr.is_started());
7791
7792        let target = SealedHost::Plain("127.0.0.1:80".parse()?);
7793
7794        let balancer_cfg = SurbBalancerConfig {
7795            target_surb_buffer_size: INITIAL_BALANCER_TARGET,
7796            max_surbs_per_sec: 100,
7797            ..Default::default()
7798        };
7799
7800        pin_mut!(new_session_rx_bob);
7801        let (alice_session, bob_session) = timeout(
7802            Duration::from_secs(2),
7803            futures::future::join(
7804                alice_mgr.new_session(
7805                    bob_peer,
7806                    SessionTarget::TcpStream(target.clone()),
7807                    SessionClientConfig {
7808                        pseudonym: alice_pseudonym.into(),
7809                        capabilities: Capability::Segmentation.into(),
7810                        surb_management: Some(balancer_cfg),
7811                        ..Default::default()
7812                    },
7813                ),
7814                new_session_rx_bob.next(),
7815            ),
7816        )
7817        .await?;
7818
7819        let mut alice_session = alice_session?;
7820        let bob_session = bob_session.ok_or(anyhow!("bob must get an incoming session"))?;
7821
7822        assert!(matches!(bob_session.target, SessionTarget::TcpStream(host) if host == target));
7823
7824        assert_eq!(
7825            Some(balancer_cfg),
7826            alice_mgr.get_surb_balancer_config(alice_session.id())?
7827        );
7828
7829        // The organic SURB gate, on the real slot rather than a hand-built state. Two things it must
7830        // get right, both of which would deadlock a Session if inverted.
7831        {
7832            let alice_slot = alice_mgr
7833                .sessions
7834                .get(alice_session.id())
7835                .ok_or(anyhow!("alice must hold the session slot"))?;
7836
7837            // At cold start the gate must be open: readiness only waits for half the target, so a
7838            // gate that closed at or below that would stop the very production it is waiting on.
7839            assert_eq!(
7840                1,
7841                alice_slot.surb_mgmt.organic_surbs_per_packet(),
7842                "a freshly opened session must still produce organic SURBs"
7843            );
7844
7845            // And it must stay open on the counterparty's word, whatever the local estimate says.
7846            alice_slot
7847                .surb_mgmt
7848                .observe_counterparty_signals(PacketSignal::OutOfSurbs.into());
7849            alice_slot
7850                .surb_mgmt
7851                .buffer_level
7852                .store(u64::MAX, std::sync::atomic::Ordering::Relaxed);
7853            assert_eq!(
7854                1,
7855                alice_slot.surb_mgmt.organic_surbs_per_packet(),
7856                "a counterparty out of SURBs must override any local estimate"
7857            );
7858
7859            // Leave the slot as it was found; the balancer loop keeps running below.
7860            alice_slot
7861                .surb_mgmt
7862                .observe_counterparty_signals(PacketSignals::default());
7863            alice_slot
7864                .surb_mgmt
7865                .buffer_level
7866                .store(0, std::sync::atomic::Ordering::Relaxed);
7867        }
7868
7869        let remote_cfg = bob_mgr
7870            .get_surb_balancer_config(bob_session.session.id())?
7871            .ok_or(anyhow!("no remote config at bob"))?;
7872        assert_eq!(remote_cfg.target_surb_buffer_size, balancer_cfg.target_surb_buffer_size);
7873        assert_eq!(
7874            remote_cfg.max_surbs_per_sec,
7875            remote_cfg.target_surb_buffer_size
7876                / bob_cfg
7877                    .minimum_surb_buffer_duration
7878                    .max(MIN_SURB_BUFFER_DURATION)
7879                    .as_secs()
7880        );
7881
7882        // Let the Surb balancer send enough KeepAlive messages
7883        tokio::time::sleep(Duration::from_millis(1500)).await;
7884
7885        let new_balancer_cfg = SurbBalancerConfig {
7886            target_surb_buffer_size: NEXT_BALANCER_TARGET,
7887            max_surbs_per_sec: 100,
7888            ..Default::default()
7889        };
7890
7891        // Update to a higher target
7892        alice_mgr.update_surb_balancer_config(alice_session.id(), new_balancer_cfg)?;
7893
7894        // Let the Surb balancer send enough KeepAlive messages
7895        tokio::time::sleep(Duration::from_millis(1500)).await;
7896
7897        // Bob should know about the updated target
7898        let remote_cfg = bob_mgr
7899            .get_surb_balancer_config(bob_session.session.id())?
7900            .ok_or(anyhow!("no remote config at bob"))?;
7901        assert_eq!(
7902            remote_cfg.target_surb_buffer_size,
7903            new_balancer_cfg.target_surb_buffer_size
7904        );
7905        assert_eq!(
7906            remote_cfg.max_surbs_per_sec,
7907            new_balancer_cfg.target_surb_buffer_size / bob_cfg.minimum_surb_buffer_duration.as_secs()
7908        );
7909
7910        let (alice_surb_sent, alice_surb_used) = alice_mgr.get_surb_level_estimates(alice_session.id())?;
7911        let (bob_surb_recv, bob_surb_used) = bob_mgr.get_surb_level_estimates(bob_session.session.id())?;
7912
7913        alice_session.close().await?;
7914
7915        assert!(alice_surb_sent > 0, "alice must've sent surbs");
7916        assert!(bob_surb_recv > 0, "bob must've received surbs");
7917        assert!(
7918            bob_surb_recv <= alice_surb_sent,
7919            "bob cannot receive more surbs than alice sent"
7920        );
7921
7922        assert!(alice_surb_used > 0, "alice must see bob used surbs");
7923        assert!(bob_surb_used > 0, "bob must've used surbs");
7924        assert!(
7925            alice_surb_used <= bob_surb_used,
7926            "alice cannot see bob used more surbs than bob actually used"
7927        );
7928
7929        tokio::time::sleep(Duration::from_millis(300)).await;
7930        assert!(matches!(
7931            alice_mgr.ping_session(alice_session.id()).await,
7932            Err(TransportSessionError::Manager(SessionManagerError::NonExistingSession))
7933        ));
7934
7935        futures::stream::iter(ahs)
7936            .for_each(|ah| async move { ah.abort() })
7937            .await;
7938
7939        // Cleanup: close senders and await handles
7940        alice_sender.close_channel();
7941        bob_sender.close_channel();
7942        alice_handle.await??;
7943        bob_handle.await??;
7944
7945        Ok(())
7946    }
7947
7948    /// Verifies that a second incoming session initiation for the same pseudonym is handled gracefully
7949    /// (returns `Ok`) without creating a duplicate session slot.
7950    ///
7951    /// ## Steps
7952    /// 1. A `SessionManager` is started with a mock transport that accepts two outbound messages.
7953    /// 2. `handle_incoming_session_initiation` is called with pseudonym `X` — succeeds; exactly one active session is
7954    ///    confirmed.
7955    /// 3. `handle_incoming_session_initiation` is called again with the same pseudonym `X`. The manager detects the
7956    ///    conflict and handles it internally by sending a `SessionError` to the peer.
7957    /// 4. The call still returns `Ok` (error is handled internally); `num_active_sessions` remains 1 with only the
7958    ///    original pseudonym present.
7959    #[test_log::test(tokio::test)]
7960    async fn session_manager_should_reject_duplicate_session_for_same_pseudonym() -> anyhow::Result<()> {
7961        use hopr_utils::network_types::prelude::SealedHost;
7962
7963        let bob_mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
7964            SessionManager::new(Default::default());
7965
7966        // Start the manager (required for handling incoming sessions)
7967        let mut transport = MockMsgSender::new();
7968        transport
7969            .expect_send_message()
7970            .times(2)
7971            .returning(|_, _| futures::future::ok(()).boxed());
7972
7973        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
7974        // Spawn a task to receive new session notifications
7975        let _notifications = tokio::spawn(async move {
7976            pin_mut!(new_session_rx);
7977            while let Some(_session) = new_session_rx.next().await {
7978                // Just drain the channel
7979            }
7980        });
7981        let (sender, _handle) = mock_packet_planning(transport);
7982        bob_mgr.start(sender.clone(), new_session_tx, None, None)?;
7983        assert!(bob_mgr.is_started());
7984
7985        let pseudonym = HoprPseudonym::random();
7986
7987        // First session initiation - should succeed
7988        let result = bob_mgr
7989            .handle_incoming_session_initiation(
7990                pseudonym,
7991                StartInitiation {
7992                    challenge: MIN_CHALLENGE,
7993                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
7994                    capabilities: HoprSessionCapabilities::empty(),
7995                    additional_data: 0,
7996                },
7997            )
7998            .await;
7999
8000        assert!(result.is_ok(), "first session initiation should succeed");
8001
8002        // Verify one session exists
8003        let active = bob_mgr.active_sessions();
8004        assert_eq!(active.len(), 1, "should have exactly one active session");
8005        assert_eq!(bob_mgr.num_active_sessions(), 1);
8006
8007        // Second session initiation with same pseudonym - should be handled gracefully
8008        // (returns Ok but sends SessionError to the requester)
8009        let result = bob_mgr
8010            .handle_incoming_session_initiation(
8011                pseudonym,
8012                StartInitiation {
8013                    challenge: MIN_CHALLENGE,
8014                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
8015                    capabilities: HoprSessionCapabilities::empty(),
8016                    additional_data: 0,
8017                },
8018            )
8019            .await;
8020
8021        // The second initiation returns Ok but handles the duplicate by sending SessionError
8022        assert!(
8023            result.is_ok(),
8024            "second session initiation should return Ok (error is handled internally)"
8025        );
8026
8027        // Verify still only one session exists
8028        let active = bob_mgr.active_sessions();
8029        assert_eq!(active.len(), 1, "should still have exactly one active session");
8030        assert_eq!(bob_mgr.num_active_sessions(), 1);
8031
8032        // Cleanup: close sender and await handle
8033        sender.close_channel();
8034        _handle.await??;
8035
8036        Ok(())
8037    }
8038
8039    /// Verifies that pinging a session that does not exist returns `NonExistingSession`.
8040    ///
8041    /// ## Steps
8042    /// 1. A `SessionManager` is started with a mock transport.
8043    /// 2. `ping_session` is called with a completely random (non-existent) session ID.
8044    /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::NonExistingSession)`.
8045    /// 4. `num_active_sessions` is 0, confirming no sessions were created.
8046    #[test_log::test(tokio::test)]
8047    async fn session_manager_should_return_error_when_pinging_non_existent_session() -> anyhow::Result<()> {
8048        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8049            SessionManager::new(Default::default());
8050
8051        let transport = MockMsgSender::new();
8052        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8053        let _notifications = tokio::spawn(async move {
8054            pin_mut!(new_session_rx);
8055            while let Some(_session) = new_session_rx.next().await {}
8056        });
8057        let (sender, _handle) = mock_packet_planning(transport);
8058        mgr.start(sender.clone(), new_session_tx, None, None)?;
8059        assert!(mgr.is_started());
8060
8061        let fake_session_id = HoprPseudonym::random();
8062        assert_eq!(mgr.num_active_sessions(), 0);
8063        let result = mgr.ping_session(&fake_session_id).await;
8064
8065        assert!(result.is_err());
8066        assert!(matches!(
8067            result.unwrap_err(),
8068            TransportSessionError::Manager(SessionManagerError::NonExistingSession)
8069        ));
8070
8071        // Cleanup: close sender and await handle
8072        sender.close_channel();
8073        _handle.await??;
8074
8075        Ok(())
8076    }
8077
8078    /// Verifies that closing a session that does not exist returns `false` (no-op).
8079    ///
8080    /// ## Steps
8081    /// 1. A `SessionManager` is started with a mock transport.
8082    /// 2. `close_session` is called with a random (non-existent) session ID.
8083    /// 3. The call returns `false`, indicating no session was closed.
8084    #[test_log::test(tokio::test)]
8085    async fn session_manager_should_return_false_when_closing_non_existent_session() -> anyhow::Result<()> {
8086        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8087            SessionManager::new(Default::default());
8088
8089        let transport = MockMsgSender::new();
8090        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8091        let _notifications = tokio::spawn(async move {
8092            pin_mut!(new_session_rx);
8093            while let Some(_session) = new_session_rx.next().await {}
8094        });
8095        let (sender, _handle) = mock_packet_planning(transport);
8096        mgr.start(sender.clone(), new_session_tx, None, None)?;
8097        assert!(mgr.is_started());
8098
8099        let fake_session_id = HoprPseudonym::random();
8100        assert_eq!(mgr.num_active_sessions(), 0);
8101        let result = mgr.close_session(&fake_session_id);
8102
8103        assert!(!result, "closing non-existent session should return false");
8104
8105        Ok(())
8106    }
8107
8108    /// Verifies that updating the SURB balancer config for a non-existent session returns an error.
8109    ///
8110    /// ## Steps
8111    /// 1. A `SessionManager` is started with a mock transport.
8112    /// 2. `update_surb_balancer_config` is called with a random session ID.
8113    /// 3. The call returns an error (no `Ok` variant is expected).
8114    #[test_log::test(tokio::test)]
8115    async fn session_manager_should_return_error_when_updating_surb_config_for_non_existent_session()
8116    -> anyhow::Result<()> {
8117        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8118            SessionManager::new(Default::default());
8119
8120        let transport = MockMsgSender::new();
8121        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8122        let _notifications = tokio::spawn(async move {
8123            pin_mut!(new_session_rx);
8124            while let Some(_session) = new_session_rx.next().await {}
8125        });
8126        let (sender, _handle) = mock_packet_planning(transport);
8127        mgr.start(sender.clone(), new_session_tx, None, None)?;
8128        assert!(mgr.is_started());
8129
8130        let fake_session_id = HoprPseudonym::random();
8131        let result = mgr.update_surb_balancer_config(&fake_session_id, SurbBalancerConfig::default());
8132
8133        assert!(result.is_err());
8134
8135        // Cleanup: close sender and await handle
8136        sender.close_channel();
8137        _handle.await??;
8138
8139        Ok(())
8140    }
8141
8142    /// Verifies that retrieving the SURB balancer config for a non-existent session returns an error.
8143    ///
8144    /// ## Steps
8145    /// 1. A `SessionManager` is started with a mock transport.
8146    /// 2. `get_surb_balancer_config` is called with a random session ID.
8147    /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::NonExistingSession)`.
8148    #[test_log::test(tokio::test)]
8149    async fn session_manager_should_return_error_when_getting_surb_config_for_non_existent_session()
8150    -> anyhow::Result<()> {
8151        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8152            SessionManager::new(Default::default());
8153
8154        let transport = MockMsgSender::new();
8155        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8156        let _notifications = tokio::spawn(async move {
8157            pin_mut!(new_session_rx);
8158            while let Some(_session) = new_session_rx.next().await {}
8159        });
8160        let (sender, _handle) = mock_packet_planning(transport);
8161        mgr.start(sender.clone(), new_session_tx, None, None)?;
8162        assert!(mgr.is_started());
8163
8164        let fake_session_id = HoprPseudonym::random();
8165        let result = mgr.get_surb_balancer_config(&fake_session_id);
8166
8167        assert!(result.is_err());
8168        assert!(matches!(
8169            result.unwrap_err(),
8170            TransportSessionError::Manager(SessionManagerError::NonExistingSession)
8171        ));
8172
8173        // Cleanup: close sender and await handle
8174        sender.close_channel();
8175        _handle.await??;
8176
8177        Ok(())
8178    }
8179
8180    /// Verifies that retrieving SURB level estimates for a non-existent session returns an error.
8181    ///
8182    /// ## Steps
8183    /// 1. A `SessionManager` is started with a mock transport.
8184    /// 2. `get_surb_level_estimates` is called with a random session ID.
8185    /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::NonExistingSession)`.
8186    #[test_log::test(tokio::test)]
8187    async fn session_manager_should_return_error_when_getting_surb_estimates_for_non_existent_session()
8188    -> anyhow::Result<()> {
8189        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8190            SessionManager::new(Default::default());
8191
8192        let transport = MockMsgSender::new();
8193        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8194        let _notifications = tokio::spawn(async move {
8195            pin_mut!(new_session_rx);
8196            while let Some(_session) = new_session_rx.next().await {}
8197        });
8198        let (sender, _handle) = mock_packet_planning(transport);
8199        mgr.start(sender.clone(), new_session_tx, None, None)?;
8200        assert!(mgr.is_started());
8201
8202        let fake_session_id = HoprPseudonym::random();
8203        let result = mgr.get_surb_level_estimates(&fake_session_id);
8204
8205        assert!(result.is_err());
8206        assert!(matches!(
8207            result.unwrap_err(),
8208            TransportSessionError::Manager(SessionManagerError::NonExistingSession)
8209        ));
8210
8211        // Cleanup: close sender and await handle
8212        sender.close_channel();
8213        _handle.await??;
8214
8215        Ok(())
8216    }
8217
8218    /// Verifies the `HoprStartProtocol::SessionError` match arm (line 689) in the
8219    /// `session_start_protocol_processor` task by calling `handle_session_error` directly.
8220    ///
8221    /// When a `SessionError` message is delivered while a `new_session` call is awaiting,
8222    /// `handle_session_error` retrieves the pending challenge from `session_initiations`,
8223    /// sends the error down the channel, and `new_session` propagates it as `Rejected`.
8224    #[test_log::test(tokio::test)]
8225    async fn handle_session_error_propagates_peer_rejection_to_pending_new_session() -> anyhow::Result<()> {
8226        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8227            SessionManager::new(Default::default());
8228
8229        let mut transport = MockMsgSender::new();
8230        // new_session sends StartSession (succeeds), then waits for SessionEstablished.
8231        // We inject the error before it arrives.
8232        transport
8233            .expect_send_message()
8234            .returning(|_, _| futures::future::ok(()).boxed());
8235
8236        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8237        let _notifications = tokio::spawn(async move {
8238            pin_mut!(new_session_rx);
8239            while let Some(_session) = new_session_rx.next().await {}
8240        });
8241        let (sender, _handle) = mock_packet_planning(transport);
8242        mgr.start(sender.clone(), new_session_tx, None, None)?;
8243        assert!(mgr.is_started());
8244
8245        // Spawn new_session so it is blocked waiting for the session establishment response.
8246        let mgr_clone = mgr.clone();
8247        let peer_address: Address = (&ChainKeypair::random()).into();
8248        let handle = tokio::spawn(async move {
8249            mgr_clone
8250                .new_session(
8251                    peer_address,
8252                    SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
8253                    SessionClientConfig {
8254                        surb_management: None,
8255                        ..Default::default()
8256                    },
8257                )
8258                .await
8259        });
8260
8261        // Give new_session time to insert the challenge into session_initiations.
8262        let challenge = tokio::time::timeout(Duration::from_secs(1), async {
8263            loop {
8264                if let Some((ch, _)) = mgr.session_initiations.iter().next() {
8265                    break *ch;
8266                }
8267                tokio::time::sleep(Duration::from_millis(10)).await;
8268            }
8269        })
8270        .await
8271        .context("new_session did not insert a challenge into session_initiations")?;
8272
8273        // Inject the SessionError with the matching challenge before SessionEstablished arrives.
8274        let error_type = StartErrorType {
8275            identifier: ErrorIdentifier::Challenge(challenge),
8276            reason: StartErrorReason::NoSlotsAvailable,
8277        };
8278        mgr.handle_session_error(error_type).await?;
8279
8280        // new_session must propagate the error as Rejected.
8281        let result = handle.await?;
8282        match result {
8283            Ok(_session) => panic!("expected rejection error, got session"),
8284            Err(e) => {
8285                assert!(matches!(
8286                    e,
8287                    TransportSessionError::Rejected(StartErrorReason::NoSlotsAvailable)
8288                ));
8289            }
8290        }
8291
8292        sender.close_channel();
8293        _handle.await??;
8294        Ok(())
8295    }
8296
8297    /// Verifies that an incoming session initiation is rejected (handled internally) when the
8298    /// manager already has `maximum_sessions` active sessions.
8299    ///
8300    /// ## Steps
8301    /// 1. A `SessionManager` is configured with `maximum_sessions: 1`.
8302    /// 2. `handle_incoming_session_initiation` is called with pseudonym `X1` — succeeds; one active session confirmed.
8303    /// 3. `handle_incoming_session_initiation` is called with pseudonym `X2` — the manager detects it is at capacity
8304    ///    and handles the conflict internally (sends `SessionError` to peer).
8305    /// 4. The call returns `Ok` (handled internally); `num_active_sessions` remains 1, with only `X1` present — `X2`
8306    ///    was rejected without creating a slot.
8307    #[test_log::test(tokio::test)]
8308    async fn session_manager_should_reject_new_session_when_max_sessions_reached() -> anyhow::Result<()> {
8309        use hopr_utils::network_types::prelude::SealedHost;
8310
8311        // Create manager with max 1 session
8312        let cfg = SessionManagerConfig {
8313            maximum_sessions: 1,
8314            ..Default::default()
8315        };
8316        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8317            SessionManager::new(cfg);
8318
8319        let mut transport = MockMsgSender::new();
8320        transport
8321            .expect_send_message()
8322            .times(2)
8323            .returning(|_, _| futures::future::ok(()).boxed());
8324
8325        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8326        let _notifications = tokio::spawn(async move {
8327            pin_mut!(new_session_rx);
8328            while let Some(_session) = new_session_rx.next().await {}
8329        });
8330        let (sender, _handle) = mock_packet_planning(transport);
8331        mgr.start(sender.clone(), new_session_tx, None, None)?;
8332        assert!(mgr.is_started());
8333
8334        // First session - should succeed
8335        let pseudonym1 = HoprPseudonym::random();
8336        mgr.handle_incoming_session_initiation(
8337            pseudonym1,
8338            StartInitiation {
8339                challenge: MIN_CHALLENGE,
8340                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
8341                capabilities: HoprSessionCapabilities::empty(),
8342                additional_data: 0,
8343            },
8344        )
8345        .await?;
8346
8347        // Verify one session exists
8348        assert_eq!(mgr.active_sessions().len(), 1);
8349        assert_eq!(mgr.num_active_sessions(), 1);
8350
8351        // Second session - should fail with TooManySessions
8352        let pseudonym2 = HoprPseudonym::random();
8353        let _result = mgr
8354            .handle_incoming_session_initiation(
8355                pseudonym2,
8356                StartInitiation {
8357                    challenge: MIN_CHALLENGE,
8358                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
8359                    capabilities: HoprSessionCapabilities::empty(),
8360                    additional_data: 0,
8361                },
8362            )
8363            .await;
8364
8365        // The error is handled internally (sends SessionError), so result is Ok
8366        // But we can verify no new session was added
8367        assert_eq!(mgr.active_sessions().len(), 1);
8368        assert_eq!(mgr.num_active_sessions(), 1);
8369
8370        // Cleanup: close sender and await handle
8371        sender.close_channel();
8372        _handle.await??;
8373
8374        Ok(())
8375    }
8376
8377    /// The node refuses a PIX Session it has no reconstructor memory left for, and does so before
8378    /// allocating anything.
8379    ///
8380    /// This is the bound `maximum_sessions` cannot express: a session slot is one slot whatever the
8381    /// peer offered, while the reconstructor state behind it is set by the peer's dimensions. Here
8382    /// `maximum_sessions` is wide open and the budget is exactly one Session's worth, so nothing but
8383    /// the budget can be doing the refusing.
8384    ///
8385    /// Refused, not queued: nothing later in establishment can give the memory back, and the peer is
8386    /// free to retry against another Exit.
8387    #[test_log::test(tokio::test)]
8388    async fn a_pix_session_over_the_live_cycle_budget_is_refused() -> anyhow::Result<()> {
8389        use hopr_utils::network_types::prelude::SealedHost;
8390
8391        // Four shares has only one legal dimension split: 2 polynomials at threshold 2. Using no
8392        // surplus makes this offer the worst-case reservation at its exact quota, so the configured
8393        // budget can honestly admit the whole range while still fitting exactly one such Session.
8394        let params = PixParams::try_new(2, 2, 0, LOCAL_PIX_SUITE)?;
8395        let quota = pix_params_to_quota(&params);
8396        let one_session = cycle_budget_for(&params, DEFAULT_SSAS_PER_SSA_REQUEST);
8397        assert_eq!(one_session, max_cycle_budget_for_quota(quota, 1));
8398
8399        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8400            SessionManager::new(SessionManagerConfig {
8401                maximum_sessions: 100,
8402                pix_config: IncomingSessionPixConfig {
8403                    quota_range: quota..=quota,
8404                    // Room for exactly one Session at these dimensions.
8405                    max_live_cycle_bytes: one_session,
8406                    ..Default::default()
8407                },
8408                ..Default::default()
8409            });
8410
8411        let ssa_gen_config = SsaGeneratorConfig {
8412            polynomials_per_ssa: 2,
8413            threshold: 2,
8414            surplus_shares: 0,
8415        };
8416        let (pix_toolbox, _pix_events_rx) = PixToolbox::new(
8417            SsaShareGenerator::new(ssa_gen_config).into(),
8418            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
8419        );
8420
8421        let mut transport = MockMsgSender::new();
8422        transport
8423            .expect_send_message()
8424            .returning(|_, _| futures::future::ok(()).boxed());
8425        let (sender, _handle) = mock_packet_planning(transport);
8426        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8427        let _notifications = tokio::spawn(async move {
8428            pin_mut!(new_session_rx);
8429            while let Some(_session) = new_session_rx.next().await {}
8430        });
8431        mgr.start(sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
8432
8433        let offer = |pseudonym| {
8434            (
8435                pseudonym,
8436                StartInitiation {
8437                    challenge: MIN_CHALLENGE,
8438                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse().unwrap())),
8439                    capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
8440                    additional_data: into_initiation_additional_data(params.into_additional_data(0)),
8441                },
8442            )
8443        };
8444
8445        let (p1, req1) = offer(HoprPseudonym::random());
8446        mgr.handle_incoming_session_initiation(p1, req1).await?;
8447        assert_eq!(1, mgr.num_active_sessions(), "the first PIX session must be admitted");
8448        assert_eq!(
8449            one_session,
8450            mgr.live_cycle_bytes.load(Ordering::Relaxed),
8451            "and must have charged exactly one session's worth"
8452        );
8453
8454        // The budget is spent, so this one is refused — handled internally as a `SessionError`,
8455        // hence `Ok` with no new slot.
8456        let (p2, req2) = offer(HoprPseudonym::random());
8457        mgr.handle_incoming_session_initiation(p2, req2).await?;
8458        assert_eq!(
8459            1,
8460            mgr.num_active_sessions(),
8461            "a session over the live-cycle budget must not be admitted"
8462        );
8463        assert_eq!(
8464            one_session,
8465            mgr.live_cycle_bytes.load(Ordering::Relaxed),
8466            "and a refused session must not leave a reservation behind"
8467        );
8468
8469        // Closing the admitted session returns its share, and the next request fits again.
8470        assert!(mgr.close_session(&p1));
8471        assert_eq!(
8472            0,
8473            mgr.live_cycle_bytes.load(Ordering::Relaxed),
8474            "closing a session must return its reservation"
8475        );
8476
8477        let (p3, req3) = offer(HoprPseudonym::random());
8478        mgr.handle_incoming_session_initiation(p3, req3).await?;
8479        assert_eq!(
8480            1,
8481            mgr.num_active_sessions(),
8482            "the freed budget must admit the next session"
8483        );
8484
8485        sender.close_channel();
8486        _handle.await??;
8487        Ok(())
8488    }
8489
8490    /// A Session is charged for the dimensions its *peer* offered, not for this node's defaults.
8491    ///
8492    /// The Exit accepts a range of dimensions, and the reconstructor state behind them differs by
8493    /// more than an order of magnitude across it. Charging a flat figure would either refuse small
8494    /// Sessions that fit easily or admit large ones that do not.
8495    #[test]
8496    fn the_live_cycle_reservation_scales_with_the_offered_dimensions() -> anyhow::Result<()> {
8497        let small = PixParams::try_new(1024, 64, 16, LOCAL_PIX_SUITE)?;
8498        let large = PixParams::try_new(8192, 64, 16, LOCAL_PIX_SUITE)?;
8499
8500        assert_eq!(
8501            8 * cycle_budget_for(&small, 1),
8502            cycle_budget_for(&large, 1),
8503            "eight times the polynomials must cost eight times the budget"
8504        );
8505        assert_eq!(
8506            3 * cycle_budget_for(&large, 1),
8507            cycle_budget_for(&large, 3),
8508            "and a batch of three must cost three times a batch of one"
8509        );
8510
8511        // The pipelining factor is in there once, and only once: a batch may have one successor
8512        // outstanding, not one per member.
8513        assert_eq!(
8514            MAX_OVERLAPPING_BATCHES * hopr_protocol_pix::peak_cycle_bytes::<HoprPixSpec>(&large),
8515            cycle_budget_for(&large, 1)
8516        );
8517
8518        // The clamp matches the one `SessionManager::new` applies, so a config that never went
8519        // through it cannot understate its own reservation.
8520        assert_eq!(cycle_budget_for(&large, 1), cycle_budget_for(&large, 0));
8521        assert_eq!(
8522            cycle_budget_for(&large, MAX_SSA_BATCH_SIZE),
8523            cycle_budget_for(&large, usize::MAX)
8524        );
8525
8526        Ok(())
8527    }
8528
8529    /// Runs an admission handler against `mgr`'s request channel for the duration of one test.
8530    ///
8531    /// Mirrors what `hopr-lib` spawns beside the session server, minus the concurrency: these tests
8532    /// drive one initiation at a time.
8533    fn spawn_admission_handler(
8534        handler: impl Fn(SessionAdmissionRequest) -> Result<SessionAdmissionDecision, String> + Send + 'static,
8535    ) -> (SessionAdmissionSink, tokio::task::JoinHandle<()>) {
8536        let (tx, mut rx) =
8537            futures::channel::mpsc::channel::<(SessionAdmissionRequest, crate::types::SessionAdmissionReply)>(4);
8538        let handle = tokio::task::spawn(async move {
8539            while let Some((request, reply)) = rx.next().await {
8540                let _ = reply.send(handler(request));
8541            }
8542        });
8543        (tx, handle)
8544    }
8545
8546    /// What [`started_manager_admitting_small_params`] hands back: the manager, its message sender
8547    /// and the task draining it, the first `SessionError` it replies with, and the incoming-session
8548    /// receiver — which the test must hold, since an admitted Session is published on it and a
8549    /// dropped receiver would turn every success into a send failure.
8550    type AdmissionFixture = (
8551        SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>,
8552        UnboundedSender<(DestinationRouting, ApplicationDataOut)>,
8553        tokio::task::JoinHandle<anyhow::Result<()>>,
8554        oneshot::Receiver<StartErrorType<SessionId>>,
8555        futures::channel::mpsc::Receiver<IncomingSession>,
8556    );
8557
8558    /// A started manager that accepts an Entry offering [`small_pix_params`], with `admission`
8559    /// installed as its session server, so that any refusal is attributable to the decision rather
8560    /// than to the dimensions or to a missing toolbox.
8561    fn started_manager_admitting_small_params(
8562        quota_range: std::ops::RangeInclusive<SsaQuota>,
8563        enforce_pix: bool,
8564        admission: Option<SessionAdmissionSink>,
8565    ) -> anyhow::Result<AdmissionFixture> {
8566        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
8567
8568        let mgr = SessionManager::new(SessionManagerConfig {
8569            pix_config: IncomingSessionPixConfig {
8570                enforce_pix,
8571                quota_range,
8572                ..Default::default()
8573            },
8574            session_admission_timeout: Duration::from_millis(200),
8575            ..Default::default()
8576        });
8577
8578        let pix_toolbox = pix_toolbox_with_pool(
8579            SsaShareGenerator::new(SsaGeneratorConfig {
8580                polynomials_per_ssa: 2,
8581                threshold: 2,
8582                surplus_shares: TEST_SURPLUS_SHARES,
8583            })
8584            .into(),
8585            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
8586        );
8587
8588        let mut transport = MockMsgSender::new();
8589        let (err_tx, err_rx) = oneshot::channel();
8590        let err_tx = Arc::new(std::sync::Mutex::new(Some(err_tx)));
8591        transport.expect_send_message().returning(move |_, data| {
8592            let err_tx = err_tx.clone();
8593            Box::pin(async move {
8594                if let Ok(HoprStartProtocol::SessionError(err)) =
8595                    HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
8596                    && let Some(tx) = err_tx.lock().unwrap().take()
8597                {
8598                    let _ = tx.send(err);
8599                }
8600                Ok(())
8601            })
8602        });
8603
8604        let (sender, handle) = mock_packet_planning(transport);
8605        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8606        mgr.start(sender.clone(), new_session_tx, Some(pix_toolbox), admission)?;
8607
8608        Ok((mgr, sender, handle, err_rx, new_session_rx))
8609    }
8610
8611    /// An Entry offering [`small_pix_params`], i.e. an offer the fixture's node accepts.
8612    fn pix_offer() -> StartInitiation<SessionTarget, HoprSessionCapabilities> {
8613        StartInitiation {
8614            challenge: MIN_CHALLENGE,
8615            target: SessionTarget::TcpStream(SealedHost::Plain(
8616                "127.0.0.1:80".parse().expect("test target must parse"),
8617            )),
8618            capabilities: HoprSessionCapabilities(Capability::Segmentation | Capability::UsePIX),
8619            additional_data: small_pix_additional_data(),
8620        }
8621    }
8622
8623    /// The same target from an Entry that offered no incentivization at all.
8624    fn no_pix_offer() -> StartInitiation<SessionTarget, HoprSessionCapabilities> {
8625        StartInitiation {
8626            capabilities: HoprSessionCapabilities(Capability::Segmentation.into()),
8627            additional_data: 0,
8628            ..pix_offer()
8629        }
8630    }
8631
8632    /// The quota an Entry offering [`small_pix_params`] asks for.
8633    fn small_params_quota() -> SsaQuota {
8634        pix_params_to_quota(&small_pix_params())
8635    }
8636
8637    /// A node's configured window is an envelope, not a default: a decision may take less of it, but
8638    /// asking for more of it than was configured — and validated, and preallocated against — leaves
8639    /// the envelope's own bounds in place.
8640    #[test]
8641    fn an_admission_decision_narrows_the_configured_quota_window_and_never_widens_it() {
8642        let cfg = IncomingSessionPixConfig {
8643            quota_range: 100..=200,
8644            ..Default::default()
8645        };
8646
8647        let narrowed = cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(120..=180));
8648        assert_eq!(narrowed.quota_range, 120..=180);
8649
8650        let widened = cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(1..=1000));
8651        assert_eq!(widened.quota_range, 100..=200, "neither bound may leave the envelope");
8652
8653        let one_end = cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(150..=u64::MAX));
8654        assert_eq!(one_end.quota_range, 150..=200, "an open end leaves that bound alone");
8655
8656        let disjoint = cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(500..=600));
8657        assert!(
8658            disjoint.quota_range.is_empty(),
8659            "a rule that does not overlap the envelope admits nothing, rather than silently picking one"
8660        );
8661    }
8662
8663    /// Unlike the quota window, `enforce_pix` is coupled to nothing validated at startup, so a
8664    /// decision simply replaces it — in either direction.
8665    #[test]
8666    fn an_admission_decision_overrides_enforce_pix_in_both_directions() {
8667        let strict = IncomingSessionPixConfig {
8668            enforce_pix: true,
8669            ..Default::default()
8670        };
8671        let lax = IncomingSessionPixConfig {
8672            enforce_pix: false,
8673            ..Default::default()
8674        };
8675
8676        assert!(strict.effective_for(&SessionAdmissionDecision::default()).enforce_pix);
8677        assert!(
8678            !strict
8679                .effective_for(&SessionAdmissionDecision::default().with_enforce_pix(false))
8680                .enforce_pix
8681        );
8682        assert!(!lax.effective_for(&SessionAdmissionDecision::default()).enforce_pix);
8683        assert!(
8684            lax.effective_for(&SessionAdmissionDecision::default().with_enforce_pix(true))
8685                .enforce_pix
8686        );
8687    }
8688
8689    /// Runs an initiation through the same two steps admission does — read the offer off the wire,
8690    /// then judge it against the node's own terms. `None` is a refusal from either.
8691    fn decode_and_check(
8692        mgr: &SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>,
8693        req: &StartInitiation<SessionTarget, HoprSessionCapabilities>,
8694    ) -> Option<(PixParams, usize)> {
8695        let policy = mgr.cfg.pix_config.effective_for(&SessionAdmissionDecision::default());
8696        decode_pix_offer(req)
8697            .ok()
8698            .and_then(|offered| mgr.check_pix_params(req.challenge, offered, &policy))
8699    }
8700
8701    /// The offer must reach the session server decoded, and the capability bits verbatim.
8702    ///
8703    /// Without it the server can only price a target blind: it names a window with no idea whether
8704    /// the peer is anywhere near it, and cannot hold a *dimension* to a floor, since the quota is
8705    /// their product and the same product admits many splits.
8706    #[test_log::test(tokio::test)]
8707    async fn the_admission_request_carries_the_decoded_offer_and_capability_bits() -> anyhow::Result<()> {
8708        let (seen_tx, seen_rx) = oneshot::channel();
8709        let seen_tx = Arc::new(std::sync::Mutex::new(Some(seen_tx)));
8710        let (admission, handler) = spawn_admission_handler(move |request| {
8711            if let Some(tx) = seen_tx.lock().unwrap().take() {
8712                let _ = tx.send((request.capabilities, request.offered));
8713            }
8714            Ok(SessionAdmissionDecision::default())
8715        });
8716        let (mgr, sender, transport, _err_rx, _incoming) =
8717            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
8718
8719        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
8720            .await?;
8721
8722        let (capabilities, offered) = seen_rx.await.context("admission was never asked")?;
8723        let offered = offered.context("an offer was made, so one must have been reported")?;
8724
8725        let params = small_pix_params();
8726        assert_eq!(offered.parts_per_ssa, params.polys_per_ssa());
8727        assert_eq!(offered.shares_per_part, params.shares_per_poly());
8728        assert_eq!(
8729            offered.surplus_shares,
8730            params.surplus_shares(),
8731            "the surplus is priced in, so a server judging the quota needs it"
8732        );
8733        assert_eq!(offered.quota_per_ssa, small_params_quota());
8734        assert_eq!(
8735            capabilities,
8736            pix_offer().capabilities.0.bits(),
8737            "the capability bits travel as the peer sent them"
8738        );
8739
8740        sender.close_channel();
8741        transport.await??;
8742        handler.abort();
8743        Ok(())
8744    }
8745
8746    /// A peer offering nothing must be reported as offering nothing, not as offering zero: the two
8747    /// are different answers, and only one of them means "this Session is free if you allow it".
8748    #[test_log::test(tokio::test)]
8749    async fn a_peer_offering_no_incentivization_reports_no_offer() -> anyhow::Result<()> {
8750        let (seen_tx, seen_rx) = oneshot::channel();
8751        let seen_tx = Arc::new(std::sync::Mutex::new(Some(seen_tx)));
8752        let (admission, handler) = spawn_admission_handler(move |request| {
8753            if let Some(tx) = seen_tx.lock().unwrap().take() {
8754                let _ = tx.send(request.offered);
8755            }
8756            Ok(SessionAdmissionDecision::default())
8757        });
8758        let (mgr, sender, transport, _err_rx, _incoming) =
8759            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
8760
8761        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), no_pix_offer())
8762            .await?;
8763
8764        assert!(
8765            seen_rx.await.context("admission was never asked")?.is_none(),
8766            "no offer must read as no offer"
8767        );
8768
8769        sender.close_channel();
8770        transport.await??;
8771        handler.abort();
8772        Ok(())
8773    }
8774
8775    /// An offer this node cannot read is refused before the session server is troubled with it: no
8776    /// decision could rescue it, and the round trip would be spent to reach the same answer.
8777    #[test_log::test(tokio::test)]
8778    async fn an_unreadable_offer_is_refused_without_asking_the_session_server() -> anyhow::Result<()> {
8779        let asked = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8780        let asked_by_handler = asked.clone();
8781        let (admission, handler) = spawn_admission_handler(move |_| {
8782            asked_by_handler.fetch_add(1, Ordering::Relaxed);
8783            Ok(SessionAdmissionDecision::default())
8784        });
8785        let (mgr, sender, transport, err_rx, _incoming) =
8786            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
8787
8788        // Zero polynomials is outside the protocol range, so unpacking refuses it outright.
8789        let unreadable = StartInitiation {
8790            additional_data: into_initiation_additional_data(((LOCAL_PIX_SUITE as u64) << 62) | (128u64 << 40)),
8791            ..pix_offer()
8792        };
8793        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), unreadable)
8794            .await?;
8795
8796        let err = err_rx.await.context("send_message was never called")?;
8797        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
8798        assert_eq!(
8799            asked.load(Ordering::Relaxed),
8800            0,
8801            "an offer that cannot be read must not cost an admission round trip"
8802        );
8803        assert_eq!(mgr.num_active_sessions(), 0);
8804
8805        sender.close_channel();
8806        transport.await??;
8807        handler.abort();
8808        Ok(())
8809    }
8810
8811    /// The feature itself: one Exit, one Entry offering the same thing, two targets, two answers.
8812    ///
8813    /// Every other test here fixes the decision and varies the offer; this one fixes the offer and
8814    /// varies the target, which is the only thing #8376 actually asks for.
8815    #[test_log::test(tokio::test)]
8816    async fn two_targets_on_one_exit_are_admitted_on_different_terms() -> anyhow::Result<()> {
8817        let free_target = SessionTarget::TcpStream(SealedHost::Plain("10.0.0.1:80".parse()?));
8818        let paid_target = SessionTarget::TcpStream(SealedHost::Plain("10.0.0.2:80".parse()?));
8819
8820        let free_for = free_target.clone();
8821        let (admission, handler) = spawn_admission_handler(move |request| {
8822            Ok(if request.target == free_for {
8823                SessionAdmissionDecision::default().with_enforce_pix(false)
8824            } else {
8825                SessionAdmissionDecision::default().with_enforce_pix(true)
8826            })
8827        });
8828
8829        // The node demands PIX; only the rule for `free_target` waives it.
8830        let (mgr, sender, transport, err_rx, _incoming) =
8831            started_manager_admitting_small_params(1..=small_params_quota(), true, Some(admission))?;
8832
8833        // Same Entry offer both times — no PIX at all.
8834        mgr.handle_incoming_session_initiation(
8835            HoprPseudonym::random(),
8836            StartInitiation {
8837                target: free_target,
8838                ..no_pix_offer()
8839            },
8840        )
8841        .await?;
8842        assert_eq!(mgr.num_active_sessions(), 1, "the waived target must be served");
8843
8844        mgr.handle_incoming_session_initiation(
8845            HoprPseudonym::random(),
8846            StartInitiation {
8847                target: paid_target,
8848                challenge: MIN_CHALLENGE + 1,
8849                ..no_pix_offer()
8850            },
8851        )
8852        .await?;
8853        assert_eq!(
8854            mgr.num_active_sessions(),
8855            1,
8856            "the second target must be refused, leaving only the first session"
8857        );
8858
8859        let err = err_rx.await.context("send_message was never called")?;
8860        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
8861        assert_eq!(
8862            err.identifier,
8863            ErrorIdentifier::Challenge(MIN_CHALLENGE + 1),
8864            "the refusal must name the second initiation, not the admitted first"
8865        );
8866
8867        sender.close_channel();
8868        transport.await??;
8869        handler.abort();
8870        Ok(())
8871    }
8872
8873    /// A queue with no reader is the third transient failure, beside the timeout and the dropped
8874    /// reply. It is `Busy` for the same reason: nothing about the target was decided.
8875    ///
8876    /// Reachable only because every request goes through one shared sender — sending through a
8877    /// fresh clone would hand each call its own guaranteed slot and the queue would never be full.
8878    #[test_log::test(tokio::test)]
8879    async fn a_full_admission_queue_refuses_the_session_as_busy() -> anyhow::Result<()> {
8880        // No receiver task at all, so nothing is ever drained. `_rx` is held rather than dropped:
8881        // a closed channel is a different failure from a full one.
8882        let (admission, _rx) =
8883            futures::channel::mpsc::channel::<(SessionAdmissionRequest, crate::types::SessionAdmissionReply)>(0);
8884
8885        let (mgr, sender, transport, err_rx, _incoming) =
8886            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
8887
8888        // The first request takes the sender's guaranteed slot; the second finds the queue full.
8889        for challenge in [MIN_CHALLENGE, MIN_CHALLENGE + 1] {
8890            mgr.handle_incoming_session_initiation(
8891                HoprPseudonym::random(),
8892                StartInitiation {
8893                    challenge,
8894                    ..pix_offer()
8895                },
8896            )
8897            .await?;
8898        }
8899
8900        let err = err_rx.await.context("send_message was never called")?;
8901        assert_eq!(err.reason, StartErrorReason::Busy);
8902        assert_eq!(mgr.num_active_sessions(), 0);
8903
8904        sender.close_channel();
8905        transport.await??;
8906        Ok(())
8907    }
8908
8909    /// The empty-intersection case is a misconfiguration; this is the ordinary one. The narrowed
8910    /// window is a real range that simply sits above what the Entry offered, and the refusal must
8911    /// look the same.
8912    #[test_log::test(tokio::test)]
8913    async fn a_narrowed_window_that_excludes_the_offer_refuses_without_being_empty() -> anyhow::Result<()> {
8914        let quota = small_params_quota();
8915        let (admission, handler) = spawn_admission_handler(move |_| {
8916            Ok(SessionAdmissionDecision::default().with_pix_quota_range(quota + 1..=2 * quota))
8917        });
8918        let (mgr, sender, transport, err_rx, _incoming) =
8919            started_manager_admitting_small_params(1..=2 * quota, false, Some(admission))?;
8920
8921        // The intersection is `quota+1..=2*quota` — non-empty, and above the offer.
8922        let effective = mgr
8923            .cfg
8924            .pix_config
8925            .effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(quota + 1..=2 * quota));
8926        assert!(!effective.quota_range.is_empty(), "this case must not be the empty one");
8927
8928        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
8929            .await?;
8930
8931        let err = err_rx.await.context("send_message was never called")?;
8932        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
8933        assert_eq!(mgr.num_active_sessions(), 0);
8934
8935        sender.close_channel();
8936        transport.await??;
8937        handler.abort();
8938        Ok(())
8939    }
8940
8941    /// Narrowing does not only accept or reject — with dynamic batching it moves the batch the Exit
8942    /// asks for, which is what the Entry pays per deposit round trip.
8943    #[test]
8944    fn a_narrowed_window_can_change_the_selected_batch_size() {
8945        let cfg = IncomingSessionPixConfig {
8946            quota_range: 100..=400,
8947            supervision: SupervisorConfig {
8948                ssas_per_request: 4,
8949                allow_dynamic_ssa_batches: true,
8950                ..Default::default()
8951            },
8952            ..Default::default()
8953        };
8954
8955        // The node's own window accepts a single SSA of 100.
8956        assert_eq!(
8957            cfg.effective_for(&SessionAdmissionDecision::default())
8958                .ssa_batch_size_for_quota(100),
8959            Some(1)
8960        );
8961
8962        // Narrowed to a floor of 250, the smallest batch that reaches it is three.
8963        assert_eq!(
8964            cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(250..=400))
8965                .ssa_batch_size_for_quota(100),
8966            Some(3)
8967        );
8968
8969        // Narrowed to a window every multiple of the offer jumps over — three total 300 and four
8970        // total 400, with nothing in between — so no batch fits and the Session is refused.
8971        assert_eq!(
8972            cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(310..=390))
8973                .ssa_batch_size_for_quota(100),
8974            None
8975        );
8976    }
8977
8978    /// Admission is an Exit-side question. An Entry establishing a Session decided its own target,
8979    /// so putting it to the local session server would be asking a node to vet itself.
8980    #[test_log::test(tokio::test)]
8981    async fn an_outgoing_session_is_never_put_to_the_admission_hook() -> anyhow::Result<()> {
8982        let asked = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8983        let asked_by_handler = asked.clone();
8984        let (admission, handler) = spawn_admission_handler(move |_| {
8985            asked_by_handler.fetch_add(1, Ordering::Relaxed);
8986            Ok(SessionAdmissionDecision::default())
8987        });
8988
8989        let mgr = SessionManager::new(SessionManagerConfig {
8990            initiation_timeout_base: Duration::from_millis(50),
8991            ..Default::default()
8992        });
8993
8994        let mut transport = MockMsgSender::new();
8995        transport
8996            .expect_send_message()
8997            .returning(|_, _| Box::pin(async { Ok(()) }));
8998        let (sender, transport_handle) = mock_packet_planning(transport);
8999        let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(1);
9000        mgr.start(sender.clone(), new_session_tx, None, Some(admission))?;
9001
9002        // No peer answers, so this times out — which is fine: the assertion is about what was asked
9003        // before the attempt died, not about it succeeding.
9004        let _ = mgr
9005            .new_session(
9006                (&hopr_api::types::crypto::keypairs::ChainKeypair::random()).into(),
9007                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9008                SessionClientConfig {
9009                    pseudonym: Some(HoprPseudonym::random()),
9010                    capabilities: Capability::Segmentation.into(),
9011                    surb_management: None,
9012                    ..Default::default()
9013                },
9014            )
9015            .await;
9016
9017        assert_eq!(
9018            asked.load(Ordering::Relaxed),
9019            0,
9020            "an outgoing session must not consult the admission hook"
9021        );
9022
9023        sender.close_channel();
9024        transport_handle.await??;
9025        handler.abort();
9026        Ok(())
9027    }
9028
9029    /// The issue's first case: a target served without payment on a node that otherwise demands it.
9030    #[test_log::test(tokio::test)]
9031    async fn a_decision_waiving_pix_admits_a_client_that_offered_none() -> anyhow::Result<()> {
9032        let (admission, handler) =
9033            spawn_admission_handler(|_| Ok(SessionAdmissionDecision::default().with_enforce_pix(false)));
9034        let (mgr, sender, transport, err_rx, _incoming) =
9035            started_manager_admitting_small_params(1..=small_params_quota(), true, Some(admission))?;
9036
9037        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), no_pix_offer())
9038            .await?;
9039
9040        assert_eq!(
9041            mgr.num_active_sessions(),
9042            1,
9043            "a waived target must be admitted even though the node enforces PIX"
9044        );
9045
9046        sender.close_channel();
9047        transport.await??;
9048        assert!(
9049            err_rx.await.is_err(),
9050            "no SessionError may be sent for an admitted session"
9051        );
9052        handler.abort();
9053        Ok(())
9054    }
9055
9056    /// And its mirror: a target that must pay on a node that otherwise does not ask.
9057    #[test_log::test(tokio::test)]
9058    async fn a_decision_demanding_pix_refuses_a_client_that_offered_none() -> anyhow::Result<()> {
9059        let (admission, handler) =
9060            spawn_admission_handler(|_| Ok(SessionAdmissionDecision::default().with_enforce_pix(true)));
9061        let (mgr, sender, transport, err_rx, _incoming) =
9062            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9063
9064        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), no_pix_offer())
9065            .await?;
9066
9067        let err = err_rx.await.context("send_message was never called")?;
9068        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
9069        assert_eq!(mgr.num_active_sessions(), 0, "no slot may be created for a refusal");
9070
9071        sender.close_channel();
9072        transport.await??;
9073        handler.abort();
9074        Ok(())
9075    }
9076
9077    /// A window narrowed past the client's offer refuses it even though the node's own window would
9078    /// have accepted it, which is the whole point of parameterizing admission by target.
9079    #[test_log::test(tokio::test)]
9080    async fn a_decision_narrowing_the_quota_past_the_offer_refuses_it() -> anyhow::Result<()> {
9081        let quota = small_params_quota();
9082        let (admission, handler) = spawn_admission_handler(move |_| {
9083            Ok(SessionAdmissionDecision::default().with_pix_quota_range(quota + 1..=u64::MAX))
9084        });
9085        let (mgr, sender, transport, err_rx, _incoming) =
9086            started_manager_admitting_small_params(1..=quota, false, Some(admission))?;
9087
9088        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9089            .await?;
9090
9091        let err = err_rx.await.context("send_message was never called")?;
9092        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
9093        assert_eq!(mgr.num_active_sessions(), 0);
9094
9095        sender.close_channel();
9096        transport.await??;
9097        handler.abort();
9098        Ok(())
9099    }
9100
9101    /// The same offer, and the same node, admitted once the decision leaves the window alone —
9102    /// which is what makes the refusal above attributable to the narrowing and nothing else.
9103    #[test_log::test(tokio::test)]
9104    async fn the_same_offer_is_admitted_when_the_decision_leaves_the_window_alone() -> anyhow::Result<()> {
9105        let quota = small_params_quota();
9106        let (admission, handler) = spawn_admission_handler(|_| Ok(SessionAdmissionDecision::default()));
9107        let (mgr, sender, transport, err_rx, _incoming) =
9108            started_manager_admitting_small_params(1..=quota, false, Some(admission))?;
9109
9110        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9111            .await?;
9112
9113        assert_eq!(mgr.num_active_sessions(), 1);
9114
9115        sender.close_channel();
9116        transport.await??;
9117        assert!(
9118            err_rx.await.is_err(),
9119            "no SessionError may be sent for an admitted session"
9120        );
9121        handler.abort();
9122        Ok(())
9123    }
9124
9125    /// A refusal is permanent and says so, so the peer does not retry a target this Exit will not
9126    /// serve however the request is phrased.
9127    #[test_log::test(tokio::test)]
9128    async fn a_refused_admission_is_reported_as_a_target_that_is_not_admitted() -> anyhow::Result<()> {
9129        let (admission, handler) = spawn_admission_handler(|_| Err("this exit does not serve that".into()));
9130        let (mgr, sender, transport, err_rx, _incoming) =
9131            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9132
9133        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9134            .await?;
9135
9136        let err = err_rx.await.context("send_message was never called")?;
9137        assert_eq!(err.reason, StartErrorReason::TargetNotAdmitted);
9138        assert_eq!(err.identifier, ErrorIdentifier::Challenge(MIN_CHALLENGE));
9139        assert_eq!(mgr.num_active_sessions(), 0, "no slot may be created for a refusal");
9140
9141        sender.close_channel();
9142        transport.await??;
9143        handler.abort();
9144        Ok(())
9145    }
9146
9147    /// A server that does not answer must not become a server that admits everything: the Session is
9148    /// refused, and as `Busy` rather than `TargetNotAdmitted`, since nothing about the target was
9149    /// decided and a retry may well succeed.
9150    #[test_log::test(tokio::test)]
9151    async fn an_admission_that_never_answers_refuses_the_session_as_busy() -> anyhow::Result<()> {
9152        // Holds every request without replying, which is what a wedged session server looks like.
9153        let (admission, mut rx) =
9154            futures::channel::mpsc::channel::<(SessionAdmissionRequest, crate::types::SessionAdmissionReply)>(4);
9155        let handler = tokio::task::spawn(async move {
9156            let mut held = Vec::new();
9157            while let Some(request) = rx.next().await {
9158                held.push(request);
9159            }
9160        });
9161
9162        let (mgr, sender, transport, err_rx, _incoming) =
9163            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9164
9165        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9166            .await?;
9167
9168        let err = err_rx.await.context("send_message was never called")?;
9169        assert_eq!(err.reason, StartErrorReason::Busy);
9170        assert_eq!(mgr.num_active_sessions(), 0);
9171
9172        sender.close_channel();
9173        transport.await??;
9174        handler.abort();
9175        Ok(())
9176    }
9177
9178    /// Same outcome when the server drops the reply instead of answering: a bug in it is still not a
9179    /// decision, and must not be read as one.
9180    #[test_log::test(tokio::test)]
9181    async fn an_admission_whose_reply_is_dropped_refuses_the_session_as_busy() -> anyhow::Result<()> {
9182        let (admission, mut rx) =
9183            futures::channel::mpsc::channel::<(SessionAdmissionRequest, crate::types::SessionAdmissionReply)>(4);
9184        let handler = tokio::task::spawn(async move {
9185            while let Some((_request, reply)) = rx.next().await {
9186                drop(reply);
9187            }
9188        });
9189
9190        let (mgr, sender, transport, err_rx, _incoming) =
9191            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9192
9193        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9194            .await?;
9195
9196        let err = err_rx.await.context("send_message was never called")?;
9197        assert_eq!(err.reason, StartErrorReason::Busy);
9198        assert_eq!(mgr.num_active_sessions(), 0);
9199
9200        sender.close_channel();
9201        transport.await??;
9202        handler.abort();
9203        Ok(())
9204    }
9205
9206    /// A node that installed no session server has nobody to ask, so it must behave exactly as it
9207    /// did before this hook existed rather than failing closed on an absent decision.
9208    #[test_log::test(tokio::test)]
9209    async fn a_manager_without_an_admission_sink_admits_on_its_own_terms() -> anyhow::Result<()> {
9210        let (mgr, sender, transport, err_rx, _incoming) =
9211            started_manager_admitting_small_params(1..=small_params_quota(), false, None)?;
9212
9213        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9214            .await?;
9215
9216        assert_eq!(
9217            mgr.num_active_sessions(),
9218            1,
9219            "an offer the node's own policy accepts must be admitted with no session server present"
9220        );
9221
9222        sender.close_channel();
9223        transport.await??;
9224        assert!(
9225            err_rx.await.is_err(),
9226            "no SessionError may be sent for an admitted session"
9227        );
9228        Ok(())
9229    }
9230
9231    /// The request the session server is handed must name the target it is being asked about, or the
9232    /// hook cannot do the one job it exists for.
9233    #[test_log::test(tokio::test)]
9234    async fn the_admission_request_carries_the_session_target() -> anyhow::Result<()> {
9235        let (seen_tx, seen_rx) = oneshot::channel();
9236        let seen_tx = Arc::new(std::sync::Mutex::new(Some(seen_tx)));
9237        let (admission, handler) = spawn_admission_handler(move |request| {
9238            if let Some(tx) = seen_tx.lock().unwrap().take() {
9239                let _ = tx.send((request.session_id, request.target.clone()));
9240            }
9241            Ok(SessionAdmissionDecision::default())
9242        });
9243        let (mgr, sender, transport, _err_rx, _incoming) =
9244            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9245
9246        let pseudonym = HoprPseudonym::random();
9247        let offer = pix_offer();
9248        let expected_target = offer.target.clone();
9249        mgr.handle_incoming_session_initiation(pseudonym, offer).await?;
9250
9251        let (seen_id, seen_target) = seen_rx.await.context("admission was never asked")?;
9252        assert_eq!(seen_target, expected_target);
9253        assert_eq!(seen_id, pseudonym, "the request must name the session it would become");
9254
9255        sender.close_channel();
9256        transport.await??;
9257        handler.abort();
9258        Ok(())
9259    }
9260
9261    /// The wait is spent inside the peer's own initiation timeout, so a configuration that outlasts
9262    /// it is normalized rather than left to expire after the peer has already given up.
9263    #[test]
9264    fn the_admission_timeout_is_clamped_below_the_initiation_budget() {
9265        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9266            SessionManager::new(SessionManagerConfig {
9267                initiation_timeout_base: Duration::from_millis(100),
9268                session_admission_timeout: Duration::from_secs(3600),
9269                ..Default::default()
9270            });
9271
9272        assert_eq!(
9273            mgr.cfg.session_admission_timeout,
9274            initiation_timeout_max_one_way(Duration::from_millis(100), RoutingOptions::MAX_INTERMEDIATE_HOPS)
9275        );
9276    }
9277
9278    /// Dynamic admission chooses the least expensive batch that brings the Entry's total offer into
9279    /// the accepted range. A cap is still a cap: it may leave the offer unsatisfiable, and checked
9280    /// multiplication must make an overflowing candidate a refusal rather than a wrapped match.
9281    #[test]
9282    fn dynamic_ssa_batches_choose_the_smallest_satisfying_batch() {
9283        // Through `effective_for` with an empty decision, which is the no-session-server path and
9284        // so yields exactly the node's configured policy.
9285        let config = |quota_range, ssas_per_request| {
9286            IncomingSessionPixConfig {
9287                quota_range,
9288                supervision: SupervisorConfig {
9289                    ssas_per_request,
9290                    allow_dynamic_ssa_batches: true,
9291                    ..Default::default()
9292                },
9293                ..Default::default()
9294            }
9295            .effective_for(&SessionAdmissionDecision::default())
9296        };
9297
9298        assert_eq!(Some(1), config(100..=350, 4).ssa_batch_size_for_quota(100));
9299        assert_eq!(Some(3), config(250..=350, 4).ssa_batch_size_for_quota(100));
9300        assert_eq!(None, config(250..=350, 2).ssa_batch_size_for_quota(100));
9301        assert_eq!(
9302            None,
9303            config(150..=175, 4).ssa_batch_size_for_quota(100),
9304            "the first multiple jumps over the range and every later one is larger"
9305        );
9306        assert_eq!(None, config(100..=350, 4).ssa_batch_size_for_quota(400));
9307        assert_eq!(
9308            None,
9309            config(u64::MAX..=u64::MAX, 2).ssa_batch_size_for_quota(u64::MAX / 2 + 1),
9310            "an overflowing batch quota must not wrap into the accepted range"
9311        );
9312    }
9313
9314    /// Disabling dynamic admission restores both halves of the old contract: only the per-SSA quota
9315    /// is compared with `quota_range`, and an accepted offer uses the configured batch exactly.
9316    #[test]
9317    fn disabling_dynamic_ssa_batches_preserves_fixed_batch_admission() {
9318        let fixed = |quota_range| {
9319            IncomingSessionPixConfig {
9320                quota_range,
9321                supervision: SupervisorConfig {
9322                    ssas_per_request: 3,
9323                    allow_dynamic_ssa_batches: false,
9324                    ..Default::default()
9325                },
9326                ..Default::default()
9327            }
9328            .effective_for(&SessionAdmissionDecision::default())
9329        };
9330
9331        assert_eq!(
9332            None,
9333            fixed(250..=350).ssa_batch_size_for_quota(100),
9334            "fixed mode must not rescue a sub-range SSA even when three of them total 300"
9335        );
9336        assert_eq!(
9337            Some(3),
9338            fixed(100..=100).ssa_batch_size_for_quota(100),
9339            "an individually accepted SSA must retain the configured exact batch"
9340        );
9341    }
9342
9343    /// Admission reserves against the batch it selected for this Session, not the configured dynamic
9344    /// ceiling. Otherwise a ceiling of nine would charge every already-satisfactory one-SSA offer as
9345    /// nine and silently divide the number of Sessions the Exit can admit.
9346    #[test]
9347    fn live_cycle_reservation_uses_the_selected_batch_size() -> anyhow::Result<()> {
9348        let params = small_pix_params();
9349        let selected_batch = 2;
9350        let expected = cycle_budget_for(&params, selected_batch);
9351        let manager: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9352            SessionManager::new(SessionManagerConfig {
9353                pix_config: IncomingSessionPixConfig {
9354                    max_live_cycle_bytes: expected,
9355                    supervision: SupervisorConfig {
9356                        ssas_per_request: 4,
9357                        ..Default::default()
9358                    },
9359                    ..Default::default()
9360                },
9361                ..Default::default()
9362            });
9363
9364        let reservation = manager
9365            .reserve_cycle_budget(&params, selected_batch)
9366            .context("the selected batch should fit exactly")?;
9367        assert_eq!(expected, manager.live_cycle_bytes.load(Ordering::Relaxed));
9368        drop(reservation);
9369        assert_eq!(0, manager.live_cycle_bytes.load(Ordering::Relaxed));
9370
9371        Ok(())
9372    }
9373
9374    /// A non-PIX Session reserves nothing.
9375    ///
9376    /// `check_pix_params` hands back nominal parameters for a peer that offered no PIX at all, and
9377    /// charging on those would bill every plain Session for reconstructor state that will never
9378    /// exist — silently capping a node that does not run PIX.
9379    #[test_log::test(tokio::test)]
9380    async fn a_non_pix_session_does_not_touch_the_live_cycle_budget() -> anyhow::Result<()> {
9381        use hopr_utils::network_types::prelude::SealedHost;
9382
9383        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9384            SessionManager::new(SessionManagerConfig {
9385                pix_config: IncomingSessionPixConfig {
9386                    // Not enough for any PIX session at all; a plain one must be unaffected.
9387                    max_live_cycle_bytes: 1,
9388                    ..Default::default()
9389                },
9390                ..Default::default()
9391            });
9392
9393        let mut transport = MockMsgSender::new();
9394        transport
9395            .expect_send_message()
9396            .returning(|_, _| futures::future::ok(()).boxed());
9397        let (sender, _handle) = mock_packet_planning(transport);
9398        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
9399        let _notifications = tokio::spawn(async move {
9400            pin_mut!(new_session_rx);
9401            while let Some(_session) = new_session_rx.next().await {}
9402        });
9403        mgr.start(sender.clone(), new_session_tx, None, None)?;
9404
9405        mgr.handle_incoming_session_initiation(
9406            HoprPseudonym::random(),
9407            StartInitiation {
9408                challenge: MIN_CHALLENGE,
9409                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9410                capabilities: HoprSessionCapabilities::empty(),
9411                additional_data: 0,
9412            },
9413        )
9414        .await?;
9415
9416        assert_eq!(1, mgr.num_active_sessions());
9417        assert_eq!(0, mgr.live_cycle_bytes.load(Ordering::Relaxed));
9418
9419        sender.close_channel();
9420        _handle.await??;
9421        Ok(())
9422    }
9423
9424    /// Verifies the early `TooManySessions` return at the top of `new_session` (line 767).
9425    /// Unlike `session_manager_should_reject_new_session_when_max_sessions_reached`, which fills
9426    /// incoming slots and hits the slot-guard path at line 957, this test fills all `maximum_sessions`
9427    /// slots so that the `if self.cfg.maximum_sessions <= self.sessions.entry_count()` check fires
9428    /// before any message is sent.
9429    #[test_log::test(tokio::test)]
9430    async fn new_session_returns_too_many_sessions_when_cache_is_full() -> anyhow::Result<()> {
9431        use hopr_utils::network_types::prelude::SealedHost;
9432
9433        let cfg = SessionManagerConfig {
9434            maximum_sessions: 2,
9435            idle_timeout: Duration::from_secs(3600),
9436            ..Default::default()
9437        };
9438        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> = SessionManager::new(cfg);
9439
9440        let mut transport = MockMsgSender::new();
9441        // Two incoming sessions: first sends SessionEstablished, second sends SessionError (no slots).
9442        transport
9443            .expect_send_message()
9444            .times(2)
9445            .returning(|_, _| futures::future::ok(()).boxed());
9446
9447        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
9448        let _notifications = tokio::spawn(async move {
9449            pin_mut!(new_session_rx);
9450            while let Some(_session) = new_session_rx.next().await {}
9451        });
9452        let (sender, _handle) = mock_packet_planning(transport);
9453        mgr.start(sender.clone(), new_session_tx, None, None)?;
9454        assert!(mgr.is_started());
9455
9456        // Fill the cache with two incoming sessions (Exits).
9457        for i in 0..2 {
9458            let pseudonym = HoprPseudonym::random();
9459            mgr.handle_incoming_session_initiation(
9460                pseudonym,
9461                StartInitiation {
9462                    challenge: MIN_CHALLENGE + i as u64,
9463                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9464                    capabilities: HoprSessionCapabilities::empty(),
9465                    additional_data: 0,
9466                },
9467            )
9468            .await?;
9469        }
9470        assert_eq!(mgr.active_sessions().len(), 2);
9471        assert_eq!(mgr.num_active_sessions(), 2);
9472
9473        // Third outgoing call hits the early return before sending anything.
9474        let result = mgr
9475            .new_session(
9476                Address::from(&ChainKeypair::random()),
9477                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9478                SessionClientConfig {
9479                    surb_management: None,
9480                    ..Default::default()
9481                },
9482            )
9483            .await;
9484
9485        assert!(result.is_err());
9486        assert!(matches!(
9487            result.unwrap_err(),
9488            TransportSessionError::Manager(SessionManagerError::TooManySessions)
9489        ));
9490
9491        sender.close_channel();
9492        _handle.await??;
9493        Ok(())
9494    }
9495
9496    /// Verifies that `session_initiations` is cleaned up when `new_session` fails to
9497    /// send the StartSession message (e.g. the underlying channel is closed).
9498    #[test_log::test(tokio::test)]
9499    async fn new_session_removes_challenge_on_send_failure() -> anyhow::Result<()> {
9500        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9501            SessionManager::new(Default::default());
9502
9503        // Create a channel whose receiver is dropped immediately.  When the mock
9504        // transport tries to `send` over this channel the call will return an error,
9505        // which propagates up through `send_via_msg_sender` as
9506        // `TransportSessionError::packet_sending`.
9507        let (tx, rx) = futures::channel::mpsc::unbounded();
9508        drop(rx);
9509
9510        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
9511        let _notifications = tokio::spawn(async move {
9512            pin_mut!(new_session_rx);
9513            while let Some(_session) = new_session_rx.next().await {}
9514        });
9515        mgr.start(tx, new_session_tx, None, None)?;
9516        assert!(mgr.is_started());
9517
9518        // Verify that sending fails because the receiver is gone.
9519        let result = mgr
9520            .new_session(
9521                Address::from(&ChainKeypair::random()),
9522                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9523                SessionClientConfig {
9524                    surb_management: None,
9525                    ..Default::default()
9526                },
9527            )
9528            .await;
9529
9530        assert!(result.is_err());
9531        assert_eq!(mgr.num_active_sessions(), 0);
9532        // The challenge must have been removed from `session_initiations` even
9533        // though the send failed.
9534        assert_eq!(
9535            mgr.session_initiations.entry_count(),
9536            0,
9537            "session_initiations was not cleaned up after send failure"
9538        );
9539
9540        Ok(())
9541    }
9542
9543    /// Verifies that `session_initiations` is cleaned up when the session initiation
9544    /// times out waiting for a response (neither `SessionEstablished` nor
9545    /// `SessionError` arrives).
9546    #[test_log::test(tokio::test)]
9547    async fn new_session_removes_challenge_on_timeout() -> anyhow::Result<()> {
9548        let cfg = SessionManagerConfig {
9549            initiation_timeout_base: Duration::from_millis(100),
9550            ..Default::default()
9551        };
9552
9553        let alice_mgr = SessionManager::new(cfg);
9554        let bob_mgr = SessionManager::new(Default::default());
9555
9556        let bob_peer: Address = (&ChainKeypair::random()).into();
9557
9558        let mut alice_transport = MockMsgSender::new();
9559        let bob_transport = MockMsgSender::new();
9560
9561        // Alice sends the StartSession message; Bob never responds.
9562        alice_transport
9563            .expect_send_message()
9564            .once()
9565            .returning(|_, _| futures::future::ok(()).boxed());
9566
9567        let (alice_sender, _alice_handle) = mock_packet_planning(alice_transport);
9568        let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
9569        alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?;
9570        assert!(alice_mgr.is_started());
9571
9572        let (bob_sender, _bob_handle) = mock_packet_planning(bob_transport);
9573        let (new_session_tx_bob, _) = futures::channel::mpsc::channel(1024);
9574        bob_mgr.start(bob_sender.clone(), new_session_tx_bob, None, None)?;
9575        assert!(bob_mgr.is_started());
9576
9577        // Record how many entries are in `session_initiations` before the call.
9578        assert_eq!(alice_mgr.session_initiations.entry_count(), 0);
9579
9580        let result = alice_mgr
9581            .new_session(
9582                bob_peer,
9583                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9584                SessionClientConfig {
9585                    capabilities: None.into(),
9586                    pseudonym: None,
9587                    surb_management: None,
9588                    ..Default::default()
9589                },
9590            )
9591            .await;
9592
9593        assert!(matches!(result, Err(TransportSessionError::Timeout)));
9594        assert_eq!(alice_mgr.num_active_sessions(), 0);
9595        // The pending challenge must have been removed from `session_initiations`
9596        // after the timeout error propagated.
9597        assert_eq!(
9598            alice_mgr.session_initiations.entry_count(),
9599            0,
9600            "session_initiations was not cleaned up after timeout"
9601        );
9602
9603        Ok(())
9604    }
9605
9606    /// Convenience: a session data packet carrying `payload`.
9607    #[cfg(test)]
9608    fn session_data_packet(payload: &[u8]) -> anyhow::Result<ApplicationDataIn> {
9609        Ok(ApplicationDataIn {
9610            data: ApplicationData::new(SESSION_APPLICATION_TAG, payload)?,
9611            packet_info: Default::default(),
9612        })
9613    }
9614
9615    /// The manager sink type used by the dispatch drop tests, which never open a session and so
9616    /// never exercise the sink — its concrete type only needs to be nameable.
9617    #[cfg(test)]
9618    type TestManager =
9619        SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>>;
9620
9621    // Test B (fully deregistered): a data packet for a session id absent from the registry is a
9622    // benign, quiet drop counted as UnknownData — NOT an error, and NOT one ERROR log per packet.
9623    // Under the old behaviour this returned `Err(UnknownData)` and the caller logged it at ERROR,
9624    // which is what produced the per-packet spam during the incident.
9625    #[test_log::test(tokio::test)]
9626    async fn dispatching_to_an_unregistered_session_should_be_a_quiet_counted_drop() -> anyhow::Result<()> {
9627        let mgr: TestManager = SessionManager::new(Default::default());
9628
9629        let pseudonym = HoprPseudonym::random();
9630        let before = crate::counters::session_unknown_data_drop_count();
9631
9632        // N packets → N drops, each an Ok(Dropped(Unregistered)), no panic, no unbounded error.
9633        const N: usize = 5;
9634        for _ in 0..N {
9635            let result = mgr.dispatch_message(pseudonym, session_data_packet(b"test data")?);
9636            assert!(
9637                matches!(result, Ok(DispatchResult::Dropped(DropReason::Unregistered))),
9638                "unregistered-session packet must be a benign Dropped(Unregistered), got {result:?}"
9639            );
9640        }
9641
9642        // Counter advanced by at least N (>= rather than == because it is a process-global atomic
9643        // other tests in this binary may also touch).
9644        assert!(crate::counters::session_unknown_data_drop_count() >= before + N);
9645
9646        Ok(())
9647    }
9648
9649    // Test A (primary regression): a data packet for a session whose data sink has been dropped
9650    // while the slot is still registered must be dropped quietly as SinkClosed — NOT an error.
9651    // This is the "sending on a disconnected channel" ERROR from the incident. The bad-behaviour
9652    // assertion is the silent-vs-loud distinction: the result is Ok(Dropped(SinkClosed)), not Err,
9653    // and specifically not SinkFull (which would misattribute a teardown as backpressure).
9654    #[test_log::test(tokio::test)]
9655    async fn dispatching_to_a_session_whose_sink_closed_should_be_a_quiet_counted_drop() -> anyhow::Result<()> {
9656        let mgr: TestManager = SessionManager::new(Default::default());
9657
9658        // `pre_populate_session` registers the slot and immediately drops its receiver, so the
9659        // sink is closed while the slot is still present — exactly the incident's race.
9660        let pseudonym = HoprPseudonym::random();
9661        mgr.pre_populate_session(pseudonym, DestinationRouting::Return(pseudonym.into()));
9662
9663        let before = crate::counters::session_inbox_closed_drop_count();
9664        let result = mgr.dispatch_message(pseudonym, session_data_packet(b"after teardown")?);
9665
9666        assert!(
9667            matches!(result, Ok(DispatchResult::Dropped(DropReason::SinkClosed))),
9668            "closed-sink packet must be a benign Dropped(SinkClosed), not an error or SinkFull, got {result:?}"
9669        );
9670        assert!(crate::counters::session_inbox_closed_drop_count() > before);
9671
9672        Ok(())
9673    }
9674
9675    // Genuine backpressure (inbox full, reader alive but not keeping up) is distinct from a
9676    // teardown: it is Dropped(SinkFull) and counted in SESSION_INBOX_DROPS, not the closed counter.
9677    #[test_log::test(tokio::test)]
9678    async fn dispatching_to_a_full_session_inbox_should_be_a_backpressure_drop() -> anyhow::Result<()> {
9679        // Capacity 1 so a single unread packet fills the inbox deterministically.
9680        let cfg = SessionManagerConfig {
9681            session_forward_capacity: 1,
9682            ..Default::default()
9683        };
9684        let mgr: TestManager = SessionManager::new(cfg);
9685
9686        // Keep the receiver alive but never read it, so the channel is open but saturates.
9687        let pseudonym = HoprPseudonym::random();
9688        let _rx = mgr.pre_populate_session_with_receiver(pseudonym, DestinationRouting::Return(pseudonym.into()));
9689
9690        // First packet fills the single slot and is accepted.
9691        let accepted = mgr.dispatch_message(pseudonym, session_data_packet(b"fills the slot")?);
9692        assert!(
9693            matches!(accepted, Ok(DispatchResult::Processed)),
9694            "first packet should be accepted, got {accepted:?}"
9695        );
9696
9697        // Second packet finds the inbox full → backpressure drop.
9698        let before = crate::counters::session_inbox_drop_count();
9699        let overflow = mgr.dispatch_message(pseudonym, session_data_packet(b"overflows")?);
9700        assert!(
9701            matches!(overflow, Ok(DispatchResult::Dropped(DropReason::SinkFull))),
9702            "overflow packet must be a Dropped(SinkFull) backpressure drop, got {overflow:?}"
9703        );
9704        assert!(crate::counters::session_inbox_drop_count() > before);
9705
9706        Ok(())
9707    }
9708
9709    /// A packet reporting that some of the SURBs it carried were dropped on arrival.
9710    /// An Entry-side slot whose organic SURB gate is closed: a target of 100 against a believed
9711    /// level of 200. Anything that reopens it in the tests below did so on the counterparty's word.
9712    fn entry_slot_with_closed_gate(
9713        mgr: &TestManager,
9714        pseudonym: HoprPseudonym,
9715    ) -> anyhow::Result<crossfire::AsyncRx<crossfire::mpsc::Array<ApplicationDataIn>>> {
9716        let rx = mgr.pre_populate_session_with_receiver(
9717            pseudonym,
9718            DestinationRouting::Forward {
9719                destination: Box::new(Address::from(&ChainKeypair::random()).into()),
9720                pseudonym: Some(pseudonym),
9721                forward_options: RoutingOptions::Hops(1.try_into()?),
9722                return_options: None,
9723            },
9724        );
9725
9726        let slot = mgr
9727            .sessions
9728            .get(&pseudonym)
9729            .ok_or_else(|| anyhow::anyhow!("slot must exist"))?;
9730        slot.surb_mgmt.update(&SurbBalancerConfig {
9731            target_surb_buffer_size: 100,
9732            ..Default::default()
9733        });
9734        slot.surb_mgmt
9735            .buffer_level
9736            .store(200, std::sync::atomic::Ordering::Relaxed);
9737        anyhow::ensure!(
9738            0 == slot.surb_mgmt.organic_surbs_per_packet(),
9739            "precondition: the gate must start closed"
9740        );
9741
9742        Ok(rx)
9743    }
9744
9745    /// The organic SURB cap the slot's gate currently answers.
9746    fn organic_surbs(mgr: &TestManager, pseudonym: HoprPseudonym) -> Option<usize> {
9747        mgr.sessions
9748            .get(&pseudonym)
9749            .map(|slot| slot.surb_mgmt.organic_surbs_per_packet())
9750    }
9751
9752    fn session_packet_signalling(signals: PacketSignals) -> anyhow::Result<ApplicationDataIn> {
9753        let mut data = session_data_packet(b"reply")?;
9754        data.packet_info.signals_from_sender = signals;
9755        Ok(data)
9756    }
9757
9758    fn keep_alive_packet_signalling(
9759        session_id: SessionId,
9760        signals: PacketSignals,
9761    ) -> anyhow::Result<ApplicationDataIn> {
9762        Ok(ApplicationDataIn {
9763            data: ApplicationData::try_from(HoprStartProtocol::KeepAlive(KeepAliveMessage {
9764                session_id,
9765                flags: KeepAliveFlag::BalancerState.into(),
9766                additional_data: 0,
9767            }))?,
9768            packet_info: IncomingPacketInfo {
9769                signals_from_sender: signals,
9770                ..Default::default()
9771            },
9772        })
9773    }
9774
9775    /// The regression: a packet dropped for local backpressure still carries what the counterparty
9776    /// said about its own SURB supply. Recorded only once the Session reads the packet, that word is
9777    /// lost exactly when the inbox is saturated — leaving organic production shut off on an estimate
9778    /// the counterparty has just contradicted, and with nothing else able to reopen it.
9779    #[test_log::test(tokio::test)]
9780    async fn distress_should_be_recorded_even_when_the_packet_is_dropped() -> anyhow::Result<()> {
9781        // Capacity 1, so the second packet deterministically finds the inbox full.
9782        let mgr: TestManager = SessionManager::new(SessionManagerConfig {
9783            session_forward_capacity: 1,
9784            ..Default::default()
9785        });
9786        let pseudonym = HoprPseudonym::random();
9787        let _rx = entry_slot_with_closed_gate(&mgr, pseudonym)?;
9788
9789        // Fill the single slot; nothing reads it.
9790        mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignals::default())?)?;
9791
9792        let dropped = mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignal::SurbDistress.into())?)?;
9793        assert!(
9794            matches!(dropped, DispatchResult::Dropped(DropReason::SinkFull)),
9795            "the fixture must actually drop this packet, got {dropped:?}"
9796        );
9797        assert_eq!(
9798            Some(1),
9799            organic_surbs(&mgr, pseudonym),
9800            "a dropped packet still spoke for the counterparty"
9801        );
9802
9803        Ok(())
9804    }
9805
9806    /// Keep-alives are all a Session that has stopped exchanging data still sends, and they reach
9807    /// `handle_keep_alive` stripped of their packet info — so before this was recorded at dispatch,
9808    /// a quiet counterparty could not report distress at all.
9809    #[test_log::test(tokio::test)]
9810    async fn distress_should_be_recorded_from_a_keep_alive() -> anyhow::Result<()> {
9811        let mgr: TestManager = SessionManager::new(Default::default());
9812        let pseudonym = HoprPseudonym::random();
9813        let _rx = entry_slot_with_closed_gate(&mgr, pseudonym)?;
9814
9815        // The manager is not started, so the Start-protocol worker rejects this after the signal has
9816        // been taken from it. That is the property under test: the record does not depend on
9817        // anything downstream succeeding.
9818        let _ = mgr.dispatch_message(
9819            pseudonym,
9820            keep_alive_packet_signalling(pseudonym, PacketSignal::OutOfSurbs.into())?,
9821        );
9822
9823        assert_eq!(Some(1), organic_surbs(&mgr, pseudonym));
9824
9825        Ok(())
9826    }
9827
9828    /// Distress is a running state, not a latch: the counterparty recomputes it per packet and stops
9829    /// setting it once its pool recovers, so a clean packet has to close the gate again.
9830    #[test_log::test(tokio::test)]
9831    async fn a_clean_packet_should_clear_distress() -> anyhow::Result<()> {
9832        let mgr: TestManager = SessionManager::new(Default::default());
9833        let pseudonym = HoprPseudonym::random();
9834        let _rx = entry_slot_with_closed_gate(&mgr, pseudonym)?;
9835
9836        mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignal::SurbDistress.into())?)?;
9837        assert_eq!(Some(1), organic_surbs(&mgr, pseudonym), "precondition: distress is set");
9838
9839        mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignals::default())?)?;
9840        assert_eq!(Some(0), organic_surbs(&mgr, pseudonym));
9841
9842        Ok(())
9843    }
9844
9845    /// The gate governs SURBs this side mints *for* its counterparty, which only the initiator does.
9846    /// On an incoming Session the same signal describes our own supply coming back to us, and acting
9847    /// on it would let a peer talk this side into minting for a buffer it does not keep.
9848    #[test_log::test(tokio::test)]
9849    async fn distress_should_be_ignored_on_an_incoming_session() -> anyhow::Result<()> {
9850        let mgr: TestManager = SessionManager::new(Default::default());
9851        let pseudonym = HoprPseudonym::random();
9852        let _rx = mgr.pre_populate_session_with_receiver(pseudonym, DestinationRouting::Return(pseudonym.into()));
9853
9854        let slot = mgr
9855            .sessions
9856            .get(&pseudonym)
9857            .ok_or_else(|| anyhow::anyhow!("slot must exist"))?;
9858        slot.surb_mgmt.update(&SurbBalancerConfig {
9859            target_surb_buffer_size: 100,
9860            ..Default::default()
9861        });
9862        slot.surb_mgmt
9863            .buffer_level
9864            .store(200, std::sync::atomic::Ordering::Relaxed);
9865
9866        mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignal::OutOfSurbs.into())?)?;
9867
9868        assert_eq!(
9869            Some(0),
9870            organic_surbs(&mgr, pseudonym),
9871            "an incoming Session must not take the signal as licence to mint"
9872        );
9873
9874        Ok(())
9875    }
9876
9877    /// Verifies that closing an existing session returns `true` and removes the session from the manager.
9878    ///
9879    /// ## Steps
9880    /// 1. A `SessionManager` is started with a mock transport that accepts one outbound message.
9881    /// 2. `handle_incoming_session_initiation` is called to create a session — one active session confirmed.
9882    /// 3. `close_session` is called with the session's pseudonym — returns `true`.
9883    /// 4. `num_active_sessions` is 0, confirming the session was fully removed.
9884    #[test_log::test(tokio::test)]
9885    async fn session_manager_should_return_true_when_closing_existing_session() -> anyhow::Result<()> {
9886        use hopr_utils::network_types::prelude::SealedHost;
9887
9888        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9889            SessionManager::new(Default::default());
9890
9891        let mut transport = MockMsgSender::new();
9892        transport
9893            .expect_send_message()
9894            .once()
9895            .returning(|_, _| futures::future::ok(()).boxed());
9896
9897        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
9898        let _notifications = tokio::spawn(async move {
9899            pin_mut!(new_session_rx);
9900            while let Some(_session) = new_session_rx.next().await {}
9901        });
9902        let (sender, _handle) = mock_packet_planning(transport);
9903        mgr.start(sender.clone(), new_session_tx, None, None)?;
9904        assert!(mgr.is_started());
9905
9906        // Create a session
9907        let pseudonym = HoprPseudonym::random();
9908        mgr.handle_incoming_session_initiation(
9909            pseudonym,
9910            StartInitiation {
9911                challenge: MIN_CHALLENGE,
9912                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9913                capabilities: HoprSessionCapabilities::empty(),
9914                additional_data: 0,
9915            },
9916        )
9917        .await?;
9918
9919        // Verify session exists
9920        assert_eq!(mgr.active_sessions().len(), 1);
9921        assert_eq!(mgr.num_active_sessions(), 1);
9922
9923        // Close the session - should return true
9924        let result = mgr.close_session(&pseudonym);
9925        assert!(result, "closing existing session should return true");
9926
9927        // Verify session is closed
9928        assert_eq!(mgr.active_sessions().len(), 0);
9929        assert_eq!(mgr.num_active_sessions(), 0);
9930
9931        // Cleanup: close sender and await handle
9932        sender.close_channel();
9933        _handle.await??;
9934
9935        Ok(())
9936    }
9937
9938    /// `mark_return_path_degraded` must mark exactly the Sessions routed to the given destination
9939    /// **that run a return path**, and return their pseudonyms (the SURB-generation keys the caller
9940    /// bumps), leaving Sessions bound for other destinations — and one-way Sessions with no return
9941    /// path — untouched.
9942    #[test_log::test(tokio::test)]
9943    async fn session_manager_mark_return_path_degraded_should_return_the_marked_pseudonyms() -> anyhow::Result<()> {
9944        use std::collections::HashSet;
9945
9946        let mgr = SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
9947
9948        let dest_a: Address = (&ChainKeypair::random()).into();
9949        let dest_b: Address = (&ChainKeypair::random()).into();
9950
9951        let hops = || RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN);
9952        let forward_to =
9953            |addr: Address, ps: HoprPseudonym, return_options: Option<RoutingOptions>| DestinationRouting::Forward {
9954                destination: Box::new(addr.into()),
9955                pseudonym: Some(ps),
9956                forward_options: hops(),
9957                return_options,
9958            };
9959
9960        // Two Sessions route to `dest_a` with a return path, one to `dest_b`, and one one-way Session
9961        // (no return path) to `dest_a` that must be skipped.
9962        let (ps_a1, ps_a2, ps_b, ps_a_oneway) = (
9963            HoprPseudonym::random(),
9964            HoprPseudonym::random(),
9965            HoprPseudonym::random(),
9966            HoprPseudonym::random(),
9967        );
9968        mgr.pre_populate_session(ps_a1, forward_to(dest_a, ps_a1, Some(hops())));
9969        mgr.pre_populate_session(ps_a2, forward_to(dest_a, ps_a2, Some(hops())));
9970        mgr.pre_populate_session(ps_b, forward_to(dest_b, ps_b, Some(hops())));
9971        mgr.pre_populate_session(ps_a_oneway, forward_to(dest_a, ps_a_oneway, None));
9972        mgr.sessions.run_pending_tasks();
9973
9974        let node_a = hopr_api::types::internal::NodeId::Chain(dest_a);
9975        let marked: HashSet<_> = mgr
9976            .mark_return_path_degraded(&node_a, Duration::from_secs(30))
9977            .into_iter()
9978            .collect();
9979        assert_eq!(
9980            marked,
9981            HashSet::from([ps_a1, ps_a2]),
9982            "only return-path Sessions routed to dest_a must be marked; the one-way Session is skipped"
9983        );
9984
9985        // A destination nothing routes to marks nothing.
9986        let node_c = hopr_api::types::internal::NodeId::Chain((&ChainKeypair::random()).into());
9987        assert!(
9988            mgr.mark_return_path_degraded(&node_c, Duration::from_secs(30))
9989                .is_empty(),
9990            "a destination with no Sessions must return no pseudonyms"
9991        );
9992
9993        Ok(())
9994    }
9995
9996    /// Verifies that an Exit `KeepAlive` updates the SURB buffer level and counts as returned service.
9997    ///
9998    /// ## Steps
9999    /// 1. A session slot is manually inserted into Alice's manager with a known `SurbBalancerConfig` and an initial
10000    ///    buffer level of 100.
10001    /// 2. A `KeepAlive` message with `KeepAliveFlag::BalancerState` and `additional_data: 200` is constructed and
10002    ///    dispatched to Alice's manager via `dispatch_message`.
10003    /// 3. The manager processes the keep-alive asynchronously; the test polls until the slot's `buffer_level` reaches
10004    ///    200 (with a 1-second timeout).
10005    /// 4. The buffer level is confirmed to be 200 and the returned-packet counter advances once.
10006    #[test_log::test(tokio::test)]
10007    async fn session_manager_should_update_buffer_level_on_keep_alive_with_balancer_state_flag() -> anyhow::Result<()> {
10008        use std::sync::atomic::Ordering;
10009
10010        let alice_pseudonym = HoprPseudonym::random();
10011        let session_id = alice_pseudonym;
10012        let initial_buffer_level = 100u64;
10013        let new_buffer_level = 200u64;
10014
10015        let balancer_cfg = SurbBalancerConfig {
10016            target_surb_buffer_size: 1000,
10017            max_surbs_per_sec: 100,
10018            ..Default::default()
10019        };
10020
10021        let alice_mgr =
10022            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
10023
10024        let (new_session_tx, _) = futures::channel::mpsc::channel(1024);
10025        let (mock_sender, _) = futures::channel::mpsc::unbounded();
10026        let _ahs = alice_mgr.start(mock_sender, new_session_tx, None, None)?;
10027        assert!(alice_mgr.is_started());
10028
10029        let (dummy_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
10030        let peer_address: Address = (&ChainKeypair::random()).into();
10031        alice_mgr.sessions.insert(
10032            session_id,
10033            SessionSlot {
10034                session_tx: dummy_tx,
10035                routing_opts: DestinationRouting::Forward {
10036                    destination: Box::new(peer_address.into()),
10037                    pseudonym: Some(alice_pseudonym),
10038                    forward_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN),
10039                    return_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN).into(),
10040                },
10041                abort_handles: Default::default(),
10042                surb_mgmt: Arc::new(BalancerStateValues::from(balancer_cfg)),
10043                surb_estimator: Default::default(),
10044                current_ssa_state: Default::default(),
10045                pix_supervisor: Default::default(),
10046                pix_egress_gate: Default::default(),
10047                pix_fill: Default::default(),
10048                returned_packets: Default::default(),
10049                cycle_budget: None,
10050            },
10051        );
10052
10053        // Set initial buffer level
10054        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10055        session_slot
10056            .surb_mgmt
10057            .buffer_level
10058            .store(initial_buffer_level, Ordering::Relaxed);
10059        drop(session_slot);
10060
10061        // Verify initial buffer level
10062        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10063        assert_eq!(session_slot.surb_mgmt.buffer_level(), initial_buffer_level);
10064        drop(session_slot);
10065
10066        // Create keep-alive message with BalancerState flag
10067        let ka = KeepAliveMessage::<SessionId> {
10068            session_id,
10069            flags: KeepAliveFlag::BalancerState.into(),
10070            additional_data: new_buffer_level,
10071        };
10072        let app_data: ApplicationData = HoprStartProtocol::KeepAlive(ka).try_into()?;
10073        let app_data_in = ApplicationDataIn {
10074            data: app_data,
10075            packet_info: Default::default(),
10076        };
10077
10078        // Dispatch the keep-alive message
10079        alice_mgr.dispatch_message(alice_pseudonym, app_data_in)?;
10080
10081        // Poll until the background task has processed the keep-alive
10082        tokio::time::timeout(Duration::from_secs(1), async {
10083            loop {
10084                if let Some(slot) = alice_mgr.sessions.get(&session_id)
10085                    && slot.surb_mgmt.buffer_level() == new_buffer_level
10086                {
10087                    break;
10088                }
10089                tokio::time::sleep(Duration::from_millis(10)).await;
10090            }
10091        })
10092        .await
10093        .context("keep-alive BalancerState update timed out")?;
10094
10095        // Verify buffer level was updated
10096        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10097        assert_eq!(
10098            session_slot.surb_mgmt.buffer_level(),
10099            new_buffer_level,
10100            "buffer level should be updated via keep-alive with BalancerState flag"
10101        );
10102        assert_eq!(
10103            1,
10104            session_slot.returned_packets.load(Ordering::Relaxed),
10105            "an Exit keep-alive consumes a return SURB and must count toward the successor gate"
10106        );
10107
10108        Ok(())
10109    }
10110
10111    /// Verifies that a `KeepAlive` message with the `BalancerTarget` flag updates the session's
10112    /// target SURB buffer size in the manager.
10113    ///
10114    /// ## Steps
10115    /// 1. A session slot is manually inserted into Alice's manager with a known `SurbBalancerConfig` and
10116    ///    `target_surb_buffer_size: 1000`.
10117    /// 2. A `KeepAlive` message with `KeepAliveFlag::BalancerTarget` and `additional_data: 2000` is constructed and
10118    ///    dispatched via `dispatch_message`.
10119    /// 3. The manager processes the keep-alive asynchronously; the test polls until the slot's
10120    ///    `target_surb_buffer_size` reaches 2000 (with a 1-second timeout).
10121    /// 4. The target is confirmed to be 2000, proving the `BalancerTarget` flag updated it.
10122    #[test_log::test(tokio::test)]
10123    async fn session_manager_should_update_target_on_keep_alive_with_balancer_target_flag() -> anyhow::Result<()> {
10124        use std::sync::atomic::Ordering;
10125
10126        let alice_pseudonym = HoprPseudonym::random();
10127        let session_id = alice_pseudonym;
10128        let initial_target = 1000u64;
10129        let new_target = 2000u64;
10130
10131        let balancer_cfg = SurbBalancerConfig {
10132            target_surb_buffer_size: initial_target,
10133            max_surbs_per_sec: 100,
10134            ..Default::default()
10135        };
10136
10137        let alice_mgr =
10138            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
10139
10140        let (new_session_tx, _) = futures::channel::mpsc::channel(1024);
10141        let (mock_sender, _) = futures::channel::mpsc::unbounded();
10142        let _ahs = alice_mgr.start(mock_sender, new_session_tx, None, None)?;
10143        assert!(alice_mgr.is_started());
10144
10145        let (dummy_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
10146        alice_mgr.sessions.insert(
10147            session_id,
10148            SessionSlot {
10149                session_tx: dummy_tx,
10150                routing_opts: DestinationRouting::Return(SurbMatcher::Pseudonym(alice_pseudonym)),
10151                abort_handles: Default::default(),
10152                surb_mgmt: Arc::new(BalancerStateValues::from(balancer_cfg)),
10153                surb_estimator: Default::default(),
10154                current_ssa_state: Default::default(),
10155                pix_supervisor: Default::default(),
10156                pix_egress_gate: Default::default(),
10157                pix_fill: Default::default(),
10158                returned_packets: Default::default(),
10159                cycle_budget: None,
10160            },
10161        );
10162
10163        // Verify initial target
10164        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10165        assert_eq!(
10166            session_slot.surb_mgmt.controller_bounds().target(),
10167            initial_target,
10168            "initial target should be set"
10169        );
10170        drop(session_slot);
10171
10172        // Create keep-alive message with BalancerTarget flag
10173        let ka = KeepAliveMessage::<SessionId> {
10174            session_id,
10175            flags: KeepAliveFlag::BalancerTarget.into(),
10176            additional_data: new_target,
10177        };
10178        let app_data: ApplicationData = HoprStartProtocol::KeepAlive(ka).try_into()?;
10179        let app_data_in = ApplicationDataIn {
10180            data: app_data,
10181            packet_info: Default::default(),
10182        };
10183
10184        // Dispatch the keep-alive message
10185        alice_mgr.dispatch_message(alice_pseudonym, app_data_in)?;
10186
10187        // Poll until the background task has processed the keep-alive
10188        tokio::time::timeout(Duration::from_secs(1), async {
10189            loop {
10190                if let Some(slot) = alice_mgr.sessions.get(&session_id)
10191                    && slot.surb_mgmt.target_surb_buffer_size.load(Ordering::Relaxed) == new_target
10192                {
10193                    break;
10194                }
10195                tokio::time::sleep(Duration::from_millis(10)).await;
10196            }
10197        })
10198        .await
10199        .context("keep-alive BalancerTarget update timed out")?;
10200
10201        // Verify target was updated
10202        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10203        assert_eq!(
10204            session_slot.surb_mgmt.target_surb_buffer_size.load(Ordering::Relaxed),
10205            new_target,
10206            "target buffer size should be updated via keep-alive with BalancerTarget flag"
10207        );
10208        assert_eq!(
10209            0,
10210            session_slot.returned_packets.load(Ordering::Relaxed),
10211            "an Entry keep-alive received on the Exit must not count as returned Exit service"
10212        );
10213
10214        Ok(())
10215    }
10216
10217    /// Verifies that a session is evicted after the `idle_timeout` fires, without needing an explicit close.
10218    ///
10219    /// ## Steps
10220    /// 1. A `SessionManager` is configured with `maximum_sessions: 1` and `idle_timeout: 100ms`.
10221    /// 2. `handle_incoming_session_initiation` creates one session — confirmed active.
10222    /// 3. The test sleeps 200ms (well past the 100ms timeout), then calls `sessions.run_pending_tasks()` to drive the
10223    ///    eviction timer.
10224    /// 4. `active_sessions` is empty, confirming the idle session was cleaned up without an explicit close call.
10225    #[test_log::test(tokio::test)]
10226    async fn session_manager_should_evict_idle_session_and_call_close_callback() -> anyhow::Result<()> {
10227        use hopr_utils::network_types::prelude::SealedHost;
10228
10229        let cfg = SessionManagerConfig {
10230            maximum_sessions: 1,
10231            idle_timeout: Duration::from_millis(100),
10232            ..Default::default()
10233        };
10234        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10235            SessionManager::new(cfg);
10236
10237        let mut transport = MockMsgSender::new();
10238        transport
10239            .expect_send_message()
10240            .times(1)
10241            .returning(|_, _| futures::future::ok(()).boxed());
10242
10243        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
10244        let _notifications = tokio::spawn(async move {
10245            pin_mut!(new_session_rx);
10246            while let Some(_session) = new_session_rx.next().await {}
10247        });
10248        let (sender, _handle) = mock_packet_planning(transport);
10249        mgr.start(sender.clone(), new_session_tx, None, None)?;
10250        assert!(mgr.is_started());
10251
10252        // Create first session
10253        let pseudonym1 = HoprPseudonym::random();
10254        mgr.handle_incoming_session_initiation(
10255            pseudonym1,
10256            StartInitiation {
10257                challenge: MIN_CHALLENGE,
10258                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10259                capabilities: HoprSessionCapabilities::empty(),
10260                additional_data: 0,
10261            },
10262        )
10263        .await?;
10264
10265        // Verify first session exists
10266        assert_eq!(mgr.active_sessions().len(), 1);
10267
10268        // Wait for the session to expire (idle_timeout = 100ms)
10269        tokio::time::sleep(Duration::from_millis(200)).await;
10270        mgr.sessions.run_pending_tasks();
10271
10272        // Verify session was evicted (cache should be empty now)
10273        assert_eq!(
10274            mgr.active_sessions().len(),
10275            0,
10276            "idle session should be evicted after timeout"
10277        );
10278
10279        Ok(())
10280    }
10281
10282    /// Verifies that a second incoming session initiation is rejected (not evicted) when the manager
10283    /// is at `maximum_sessions` capacity with a long `idle_timeout`.
10284    ///
10285    /// ## Steps
10286    /// 1. A `SessionManager` is configured with `maximum_sessions: 1` and `idle_timeout: 3600s` (long enough that
10287    ///    eviction will not fire during the test).
10288    /// 2. `handle_incoming_session_initiation` creates session `X1` — confirmed active.
10289    /// 3. `handle_incoming_session_initiation` is called for session `X2` — the manager detects capacity is reached and
10290    ///    rejects the initiation internally (sends `SessionError`).
10291    /// 4. `active_sessions` still contains only `X1`; the first session was not evicted to make room.
10292    #[test_log::test(tokio::test)]
10293    async fn session_manager_should_reject_new_session_when_max_sessions_reached_no_eviction() -> anyhow::Result<()> {
10294        use hopr_utils::network_types::prelude::SealedHost;
10295
10296        // Create manager with max 1 session
10297        let cfg = SessionManagerConfig {
10298            maximum_sessions: 1,
10299            idle_timeout: Duration::from_secs(3600), // Long timeout so eviction doesn't happen
10300            ..Default::default()
10301        };
10302        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10303            SessionManager::new(cfg);
10304
10305        let mut transport = MockMsgSender::new();
10306        transport
10307            .expect_send_message()
10308            .times(2)
10309            .returning(|_, _| futures::future::ok(()).boxed());
10310
10311        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
10312        let _notifications = tokio::spawn(async move {
10313            pin_mut!(new_session_rx);
10314            while let Some(_session) = new_session_rx.next().await {}
10315        });
10316        let (sender, _handle) = mock_packet_planning(transport);
10317        mgr.start(sender.clone(), new_session_tx, None, None)?;
10318        assert!(mgr.is_started());
10319
10320        // Create first session
10321        let pseudonym1 = HoprPseudonym::random();
10322        mgr.handle_incoming_session_initiation(
10323            pseudonym1,
10324            StartInitiation {
10325                challenge: MIN_CHALLENGE,
10326                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10327                capabilities: HoprSessionCapabilities::empty(),
10328                additional_data: 0,
10329            },
10330        )
10331        .await?;
10332
10333        // Verify first session exists
10334        assert_eq!(mgr.active_sessions().len(), 1);
10335
10336        // Try to create second session - should be rejected (not evicted)
10337        let pseudonym2 = HoprPseudonym::random();
10338        let _result = mgr
10339            .handle_incoming_session_initiation(
10340                pseudonym2,
10341                StartInitiation {
10342                    challenge: MIN_CHALLENGE,
10343                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10344                    capabilities: HoprSessionCapabilities::empty(),
10345                    additional_data: 0,
10346                },
10347            )
10348            .await;
10349
10350        // Should still have exactly 1 session (the first one)
10351        assert_eq!(
10352            mgr.active_sessions().len(),
10353            1,
10354            "should still have exactly one session - second session should be rejected"
10355        );
10356
10357        // The active session should be the first one (second was rejected)
10358        assert!(
10359            mgr.active_sessions().contains(&pseudonym1),
10360            "the first session should still be active"
10361        );
10362
10363        // Cleanup: close sender and await handle
10364        sender.close_channel();
10365        _handle.await??;
10366
10367        Ok(())
10368    }
10369
10370    /// Verifies that `new_session` rejects `UsePIX` when the return path has 0 intermediate hops.
10371    ///
10372    /// PIX shares are encrypted with the first relayer's ticket-challenge solution and carried
10373    /// in return-path SURBs. With 0 intermediate hops (a direct Exit→Entry SURB), there is no
10374    /// relayer to provide the challenge solution, so shares are never embedded — the ongoing
10375    /// PIX share delivery mechanism is dead and the Exit's quota is never replenished.
10376    #[test_log::test(tokio::test)]
10377    async fn new_session_rejects_usepix_with_zero_return_hops() -> anyhow::Result<()> {
10378        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10379            SessionManager::new(Default::default());
10380
10381        let mut transport = MockMsgSender::new();
10382        // The error happens before any message is sent, so expect_send_message should NOT fire.
10383        transport.expect_send_message().times(0);
10384
10385        let (sender, _handle) = mock_packet_planning(transport);
10386        let (new_session_tx, _) = futures::channel::mpsc::channel(1);
10387        mgr.start(sender.clone(), new_session_tx, None, None)?;
10388        assert!(mgr.is_started());
10389
10390        let result = mgr
10391            .new_session(
10392                Address::from(&ChainKeypair::random()),
10393                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10394                SessionClientConfig {
10395                    capabilities: Capability::UsePIX.into(),
10396                    surb_management: None,
10397                    forward_path_options: RoutingOptions::Hops(1.try_into()?),
10398                    return_path_options: RoutingOptions::Hops(0.try_into()?),
10399                    ..Default::default()
10400                },
10401            )
10402            .await;
10403
10404        let err = result.unwrap_err();
10405        let msg = format!("{err:?}");
10406        assert!(
10407            msg.contains("UsePIX requires at least 1 intermediate hop on the return path"),
10408            "expected return-path guard error, got: {msg}"
10409        );
10410
10411        assert_eq!(mgr.num_active_sessions(), 0);
10412        // No challenge slot was consumed because the validations run before insert_into_next_slot.
10413        assert_eq!(
10414            mgr.session_initiations.entry_count(),
10415            0,
10416            "session_initiations must remain empty when UsePIX is rejected"
10417        );
10418
10419        sender.close_channel();
10420        _handle.await??;
10421        Ok(())
10422    }
10423
10424    /// Verifies that `new_session` rejects `UsePIX` on a node with no `PixToolbox` installed.
10425    ///
10426    /// Since the announced dimensions are read off the installed generator rather than named by the
10427    /// caller, the presence of that generator is the *only* precondition left for an outgoing PIX
10428    /// Session — there is no longer a caller-supplied value whose absence would fail first. A node
10429    /// that advertised `UsePIX` without one would establish a Session it cannot produce a single
10430    /// share for.
10431    ///
10432    /// The return path carries 2 intermediate hops so the zero-hop guard above cannot be what
10433    /// rejects this, and the assertion names the toolbox error specifically for the same reason.
10434    #[test_log::test(tokio::test)]
10435    async fn new_session_rejects_usepix_when_no_pix_toolbox_is_installed() -> anyhow::Result<()> {
10436        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10437            SessionManager::new(Default::default());
10438
10439        let mut transport = MockMsgSender::new();
10440        // The error happens before any message is sent, so expect_send_message should NOT fire.
10441        transport.expect_send_message().times(0);
10442
10443        let (sender, _handle) = mock_packet_planning(transport);
10444        let (new_session_tx, _) = futures::channel::mpsc::channel(1);
10445        // `None` for the toolbox: this is a node that cannot serve PIX at all.
10446        mgr.start(sender.clone(), new_session_tx, None, None)?;
10447        assert!(mgr.is_started());
10448
10449        let result = mgr
10450            .new_session(
10451                Address::from(&ChainKeypair::random()),
10452                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10453                SessionClientConfig {
10454                    capabilities: Capability::UsePIX.into(),
10455                    surb_management: None,
10456                    forward_path_options: RoutingOptions::Hops(1.try_into()?),
10457                    return_path_options: RoutingOptions::Hops(2.try_into()?),
10458                    ..Default::default()
10459                },
10460            )
10461            .await;
10462
10463        let msg = format!("{:?}", result.unwrap_err());
10464        assert!(
10465            msg.contains("UsePIX requested but no PIX toolbox installed"),
10466            "expected missing-toolbox error, got: {msg}"
10467        );
10468
10469        assert_eq!(mgr.num_active_sessions(), 0);
10470        // No challenge slot was consumed because the validations run before insert_into_next_slot.
10471        assert_eq!(
10472            mgr.session_initiations.entry_count(),
10473            0,
10474            "session_initiations must remain empty when UsePIX is rejected for a missing toolbox"
10475        );
10476
10477        sender.close_channel();
10478        _handle.await??;
10479        Ok(())
10480    }
10481
10482    // ---------------------------------------------------------------------------
10483    // PIX protocol tests
10484    // ---------------------------------------------------------------------------
10485
10486    /// The fill fractions must be range-checked before anything computes with them.
10487    ///
10488    /// This validator runs *before* `validate_pix_supervision` on both paths that reach it:
10489    /// `SessionManager::start` calls it first, and in `HoprProtocolConfig` it is a field validator
10490    /// while the other is a schema-level one. So it cannot assume the ranges have already been
10491    /// checked — and it feeds `finish_fraction` straight into `Duration::mul_f64`, which panics on a
10492    /// `NaN`, on a negative, and on a product the monotonic clock cannot hold. A single
10493    /// `finish_fraction: .nan` in a YAML file would therefore abort the node at load instead of being
10494    /// reported as the configuration error it is.
10495    ///
10496    /// The finite out-of-range values do not panic, which is why they are here too: they compute a
10497    /// floor that is wrong in the permissive direction, and nothing downstream ever says so.
10498    #[test]
10499    fn fill_fractions_are_rejected_before_the_ceiling_arithmetic_runs() {
10500        let with_fill = |fill: crate::supervision::PixFillConfig| IncomingSessionPixConfig {
10501            supervision: SupervisorConfig {
10502                fill,
10503                ..Default::default()
10504            },
10505            ..Default::default()
10506        };
10507
10508        for finish_fraction in [f64::NAN, -1.0, f64::INFINITY, 2.0] {
10509            let cfg = with_fill(crate::supervision::PixFillConfig {
10510                finish_fraction,
10511                ..Default::default()
10512            });
10513            let outcome = validate_incoming_session_pix_config(&cfg, MAX_ASSUMED_SESSION_PACKET_RATE);
10514            assert!(
10515                matches!(&outcome, Err(TransportSessionError::InvalidConfig(msg)) if msg.contains("fill.finish_fraction")),
10516                "a finish_fraction of {finish_fraction} must be refused rather than multiplied, got {outcome:?}"
10517            );
10518        }
10519
10520        for loss_margin in [f64::NAN, -0.5, 1.0] {
10521            let cfg = with_fill(crate::supervision::PixFillConfig {
10522                loss_margin,
10523                ..Default::default()
10524            });
10525            let outcome = validate_incoming_session_pix_config(&cfg, MAX_ASSUMED_SESSION_PACKET_RATE);
10526            assert!(
10527                matches!(&outcome, Err(TransportSessionError::InvalidConfig(msg)) if msg.contains("fill.loss_margin")),
10528                "a loss_margin of {loss_margin} must be refused, got {outcome:?}"
10529            );
10530        }
10531
10532        validate_incoming_session_pix_config(&IncomingSessionPixConfig::default(), MAX_ASSUMED_SESSION_PACKET_RATE)
10533            .expect("the shipped defaults must still validate");
10534    }
10535
10536    /// A zero recovery deadline must be named as such on both fill paths, rather than reaching the
10537    /// fill arithmetic and being reported as an infinite rate requirement.
10538    #[test]
10539    fn a_zero_recovery_deadline_is_refused_as_zero_with_and_without_fill() {
10540        for enabled in [true, false] {
10541            let cfg = IncomingSessionPixConfig {
10542                supervision: SupervisorConfig {
10543                    max_recovery_time: Duration::ZERO,
10544                    fill: crate::supervision::PixFillConfig {
10545                        enabled,
10546                        ..Default::default()
10547                    },
10548                    ..Default::default()
10549                },
10550                ..Default::default()
10551            };
10552            let outcome = validate_incoming_session_pix_config(&cfg, MAX_ASSUMED_SESSION_PACKET_RATE);
10553            assert!(
10554                matches!(&outcome, Err(TransportSessionError::InvalidConfig(msg)) if msg.contains("max_recovery_time must be non-zero")),
10555                "with fill enabled = {enabled}, a zero deadline must be refused as zero, got {outcome:?}"
10556            );
10557        }
10558    }
10559
10560    /// The fill heartbeat is clamped at *both* ends for a programmatically assembled config.
10561    ///
10562    /// The upper clamp is the one every other supervisor duration takes. The lower one is what stops
10563    /// a zero period, which `validate_pix_supervision` rejects and nothing in this crate calls: with
10564    /// it, `FillRate::as_packets_per_sec` reports zero for the heartbeat, so the planner's floor is
10565    /// zero, both heartbeat branches of `FillPlanner::bound` return a zero-period rate, and
10566    /// `PixFillControl::apply` hands `set_rate_per_unit(1, 1µs)` to the controller — which
10567    /// `RateController::MIN_DELAY` turns into 10 000 packets/s. The `max_rate` ceiling never sees it,
10568    /// so the one bound on this node's self-generated egress would be bypassed by the one field left
10569    /// unbounded below.
10570    #[test]
10571    fn programmatic_fill_heartbeat_is_clamped_to_a_positive_period() {
10572        let normalized = |heartbeat: Duration| {
10573            let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10574                SessionManager::new(SessionManagerConfig {
10575                    pix_config: IncomingSessionPixConfig {
10576                        supervision: SupervisorConfig {
10577                            fill: crate::supervision::PixFillConfig {
10578                                heartbeat,
10579                                ..Default::default()
10580                            },
10581                            ..Default::default()
10582                        },
10583                        ..Default::default()
10584                    },
10585                    ..Default::default()
10586                });
10587            mgr.cfg.pix_config.supervision.fill.heartbeat
10588        };
10589
10590        // The exact dual of the `max_rate` ceiling, so the two clamps cannot drift apart.
10591        let floor = Duration::from_secs(1) / crate::supervision::MAX_FILL_RATE;
10592        assert_eq!(
10593            floor,
10594            normalized(Duration::ZERO),
10595            "a zero heartbeat must be raised to the period of MAX_FILL_RATE"
10596        );
10597        assert_eq!(
10598            crate::supervision::MAX_SUPERVISOR_DURATION,
10599            normalized(Duration::MAX),
10600            "an unrepresentable heartbeat must be lowered to the same cap every other deadline takes"
10601        );
10602
10603        // Which is the property the clamp exists for: a rate the planner can divide by.
10604        assert!(
10605            FillRate::once_per(normalized(Duration::ZERO)).as_packets_per_sec() > 0.0,
10606            "the clamped heartbeat must be a rate the planner can compare against its ceiling"
10607        );
10608    }
10609
10610    /// Supervisor durations a programmatic caller supplies must be clamped, not trusted.
10611    ///
10612    /// `validate_pix_supervision` rejects these, but nothing in this crate calls `validate` — a
10613    /// `SessionManagerConfig` assembled in code reaches the supervisor exactly as written. And the
10614    /// failure is silent in the worst direction: `Instant::checked_add` returns `None` for a duration
10615    /// the monotonic clock cannot represent, and every phase reads an absent deadline as *no
10616    /// deadline*, so the over-large value disables the rule rather than relaxing it.
10617    ///
10618    /// The two batch-scaled deadlines are clamped by what is actually armed, so a batch of `n` leaves
10619    /// each of them at a cap `n` times smaller.
10620    #[test]
10621    fn programmatic_supervisor_durations_are_clamped_to_a_representable_deadline() {
10622        const BATCH: usize = 4;
10623        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10624            SessionManager::new(SessionManagerConfig {
10625                pix_config: IncomingSessionPixConfig {
10626                    supervision: SupervisorConfig {
10627                        ssas_per_request: BATCH,
10628                        max_ssa_delivery_time: Duration::MAX,
10629                        max_deposit_wait: Duration::MAX,
10630                        max_recovery_idle: Duration::MAX,
10631                        max_recovery_time: Duration::MAX,
10632                        tombstone_retention_window: Duration::MAX,
10633                        ..Default::default()
10634                    },
10635                    ..Default::default()
10636                },
10637                ..Default::default()
10638            });
10639
10640        let sup = &mgr.cfg.pix_config.supervision;
10641        let cap = crate::supervision::MAX_SUPERVISOR_DURATION;
10642        let per_cycle_cap = cap / BATCH as u32;
10643
10644        assert_eq!(per_cycle_cap, sup.max_ssa_delivery_time);
10645        assert_eq!(per_cycle_cap, sup.max_deposit_wait);
10646        assert_eq!(cap, sup.max_recovery_time);
10647        assert_eq!(cap, sup.tombstone_retention_window);
10648
10649        // `max_recovery_idle` is bounded by something tighter than the clock: it must stay strictly
10650        // under the reconstructor's `unused_verifier_lifetime`, or the supervisor waits on a cycle
10651        // whose state was reclaimed long before. Normalized against the default reconstructor, which
10652        // is the one `SessionManager::new` can see; `start` re-checks against the installed one.
10653        let lifetime = hopr_protocol_pix::SsaReconstructorConfig::default().unused_verifier_lifetime;
10654        assert!(
10655            sup.max_recovery_idle < lifetime,
10656            "clamped max_recovery_idle ({:?}) must stay under the reconstructor lifetime ({lifetime:?})",
10657            sup.max_recovery_idle
10658        );
10659        crate::supervision::validate_pix_supervision(sup, &hopr_protocol_pix::SsaReconstructorConfig::default())
10660            .expect("a normalized config must satisfy every cross-component invariant, not only representability");
10661
10662        // Which is exactly the condition that makes every deadline representable.
10663        let now = std::time::Instant::now();
10664        for dur in [
10665            crate::supervision::scaled_deadline(sup.max_ssa_delivery_time, sup.ssas_per_request),
10666            crate::supervision::scaled_deadline(sup.max_deposit_wait, sup.ssas_per_request),
10667            sup.max_recovery_idle,
10668            sup.max_recovery_time,
10669            sup.tombstone_retention_window,
10670        ] {
10671            assert!(
10672                now.checked_add(dur).is_some(),
10673                "a clamped duration ({dur:?}) must still produce a deadline"
10674            );
10675        }
10676    }
10677
10678    /// Normalizing against the default reconstructor is not enough when a caller installs a
10679    /// different one.
10680    ///
10681    /// `SessionManager::new` cannot see the reconstructor — it arrives with the toolbox at `start` —
10682    /// so its clamp can only assume the defaults. A caller pairing a shorter
10683    /// `unused_verifier_lifetime` with a supervisor config that was valid against the defaults gets
10684    /// a supervisor that outlives the state it is waiting on. That must be an error rather than a
10685    /// silent clamp: at `start` both halves were chosen deliberately, and overriding one of them
10686    /// would be answering a question the caller did not ask.
10687    #[test]
10688    fn start_rejects_a_supervisor_config_the_installed_reconstructor_cannot_support() -> anyhow::Result<()> {
10689        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
10690
10691        // Valid against the defaults — 10 min sits well under the default 30 min lifetime — and
10692        // therefore untouched by the constructor's clamp.
10693        let cfg = SessionManagerConfig {
10694            pix_config: IncomingSessionPixConfig {
10695                supervision: SupervisorConfig {
10696                    max_recovery_idle: Duration::from_secs(600),
10697                    ..Default::default()
10698                },
10699                ..Default::default()
10700            },
10701            ..Default::default()
10702        };
10703        crate::supervision::validate_pix_supervision(&cfg.pix_config.supervision, &SsaReconstructorConfig::default())
10704            .expect("the fixture must be valid against the default reconstructor");
10705
10706        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> = SessionManager::new(cfg);
10707
10708        // But the reconstructor actually installed reclaims a cycle after 60 s.
10709        let (pix_toolbox, _pix_events) = PixToolbox::new(
10710            SsaShareGenerator::new(SsaGeneratorConfig::default()).into(),
10711            SsaReconstructor::new(SsaReconstructorConfig {
10712                unused_verifier_lifetime: Duration::from_secs(60),
10713                ..Default::default()
10714            })
10715            .into(),
10716        );
10717
10718        let (tx, _rx) = futures::channel::mpsc::unbounded();
10719        let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(1);
10720        let started = mgr.start(tx, new_session_tx, Some(pix_toolbox), None);
10721
10722        assert!(
10723            matches!(started, Err(TransportSessionError::InvalidConfig(_))),
10724            "starting with a reconstructor that cannot support the supervisor config must fail, got {started:?}"
10725        );
10726
10727        Ok(())
10728    }
10729
10730    /// A programmatically assembled manager must enforce the same incoming-PIX cross-field rules
10731    /// as the serialized transport configuration before it starts accepting Sessions.
10732    #[allow(clippy::reversed_empty_ranges)]
10733    #[test_log::test(tokio::test)]
10734    async fn start_rejects_each_invalid_incoming_pix_cross_field_combination() -> anyhow::Result<()> {
10735        let valid = IncomingSessionPixConfig::default();
10736        let widest = max_cycle_budget_for_quota(*valid.quota_range.end(), valid.supervision.ssas_per_request);
10737        let invalid_configs = [
10738            (
10739                "empty quota range",
10740                IncomingSessionPixConfig {
10741                    quota_range: 100..=10,
10742                    ..valid.clone()
10743                },
10744                "quota_range",
10745            ),
10746            (
10747                "recovery deadline below one accepted cycle",
10748                IncomingSessionPixConfig {
10749                    supervision: SupervisorConfig {
10750                        max_recovery_time: Duration::from_secs(1),
10751                        ..valid.supervision.clone()
10752                    },
10753                    ..valid.clone()
10754                },
10755                "max_recovery_time",
10756            ),
10757            (
10758                "live-cycle budget below one accepted cycle",
10759                IncomingSessionPixConfig {
10760                    max_live_cycle_bytes: widest - 1,
10761                    ..valid
10762                },
10763                "max_live_cycle_bytes",
10764            ),
10765        ];
10766
10767        for (case, pix_config, expected_field) in invalid_configs {
10768            let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10769                SessionManager::new(SessionManagerConfig {
10770                    pix_config,
10771                    ..Default::default()
10772                });
10773            let (pix_toolbox, _pix_events) = PixToolbox::new(
10774                SsaShareGenerator::new(SsaGeneratorConfig::default()).into(),
10775                SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
10776            );
10777            let (tx, _rx) = futures::channel::mpsc::unbounded();
10778            let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(1);
10779
10780            match mgr.start(tx, new_session_tx, Some(pix_toolbox), None) {
10781                Err(TransportSessionError::InvalidConfig(message)) => assert!(
10782                    message.contains(expected_field),
10783                    "{case} should identify {expected_field}, got: {message}"
10784                ),
10785                Err(error) => anyhow::bail!("{case} returned the wrong error: {error}"),
10786                Ok(_) => anyhow::bail!("{case} was accepted"),
10787            }
10788        }
10789
10790        Ok(())
10791    }
10792
10793    /// A rejected PIX configuration must not consume the manager's one-shot start state.
10794    ///
10795    /// Validation is fallible and happens before any worker is spawned, so callers must be able to
10796    /// correct the toolbox (or start without PIX) and retry the same manager. Otherwise `start`
10797    /// reports an ordinary configuration error while leaving `is_started() == false`, but the
10798    /// already-filled message-sender lock makes every retry fail with `AlreadyStarted`.
10799    ///
10800    /// On a runtime because the retry now gets far enough to spawn the manager's workers, which is
10801    /// the whole point — before the fix it returned `AlreadyStarted` before reaching them.
10802    #[test_log::test(tokio::test)]
10803    async fn rejected_pix_start_does_not_poison_a_retry() -> anyhow::Result<()> {
10804        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
10805
10806        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10807            SessionManager::new(SessionManagerConfig {
10808                pix_config: IncomingSessionPixConfig {
10809                    supervision: SupervisorConfig {
10810                        max_recovery_idle: Duration::from_secs(600),
10811                        ..Default::default()
10812                    },
10813                    ..Default::default()
10814                },
10815                ..Default::default()
10816            });
10817
10818        let (invalid_pix, _pix_events) = PixToolbox::new(
10819            SsaShareGenerator::new(SsaGeneratorConfig::default()).into(),
10820            SsaReconstructor::new(SsaReconstructorConfig {
10821                unused_verifier_lifetime: Duration::from_secs(60),
10822                ..Default::default()
10823            })
10824            .into(),
10825        );
10826        let (first_tx, _first_rx) = futures::channel::mpsc::unbounded();
10827        let (first_notifier, _first_notifications) = futures::channel::mpsc::channel(1);
10828        assert!(matches!(
10829            mgr.start(first_tx, first_notifier, Some(invalid_pix), None),
10830            Err(TransportSessionError::InvalidConfig(_))
10831        ));
10832        assert!(
10833            !mgr.is_started(),
10834            "a rejected start must not report the manager as running"
10835        );
10836
10837        let (retry_tx, _retry_rx) = futures::channel::mpsc::unbounded();
10838        let (retry_notifier, _retry_notifications) = futures::channel::mpsc::channel(1);
10839        let retry = mgr.start(retry_tx, retry_notifier, None, None);
10840        assert!(
10841            retry.is_ok(),
10842            "a configuration error must leave the manager retryable, got {retry:?}"
10843        );
10844
10845        Ok(())
10846    }
10847
10848    /// Making every duration representable is not sufficient if normalization leaves a supervisor
10849    /// waiting after its paired reconstructor has already discarded the cycle. A programmatic
10850    /// caller using the default reconstructor must receive a configuration that still satisfies the
10851    /// same cross-component lifetime invariants as a file-loaded configuration.
10852    #[test]
10853    fn programmatic_clamping_preserves_reconstructor_lifetime_invariants() {
10854        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10855            SessionManager::new(SessionManagerConfig {
10856                pix_config: IncomingSessionPixConfig {
10857                    supervision: SupervisorConfig {
10858                        max_recovery_idle: Duration::MAX,
10859                        ..Default::default()
10860                    },
10861                    ..Default::default()
10862                },
10863                ..Default::default()
10864            });
10865
10866        crate::supervision::validate_pix_supervision(
10867            &mgr.cfg.pix_config.supervision,
10868            &SsaReconstructorConfig::default(),
10869        )
10870        .expect(
10871            "normalizing a programmatic config must not leave recovery-idle beyond the reconstructor's state lifetime",
10872        );
10873    }
10874
10875    /// Verifies that an incoming Session whose PIX quota cannot reach the acceptable range within
10876    /// the configured dynamic batch ceiling is rejected with
10877    /// `StartErrorReason::UnacceptablePixParams`.
10878    ///
10879    /// ## Steps
10880    /// 1. Bob's manager accepts exactly two times the Entry's per-SSA quota, but leaves the dynamic batch ceiling at
10881    ///    its default of one.
10882    /// 2. The test offers valid PIX dimensions whose quota would therefore need a batch of two.
10883    /// 3. `handle_incoming_session_initiation` is called with `Capability::UsePIX` and the out-of-range quota.
10884    /// 4. Bob's manager sends a `SessionError` back to the peer with reason `UnacceptablePixParams`.
10885    /// 5. The test receives the error on a one-shot channel and asserts `err.reason == UnacceptablePixParams` and
10886    ///    `err.challenge == MIN_CHALLENGE`.
10887    /// 6. `num_active_sessions` is 0, confirming no session slot was created.
10888    #[test_log::test(tokio::test)]
10889    async fn incoming_session_with_unacceptable_pix_quota_is_rejected() -> anyhow::Result<()> {
10890        use std::sync::Arc;
10891
10892        use hopr_protocol_pix::SsaReconstructorConfig;
10893        use hopr_protocol_start::{StartErrorReason, StartInitiation};
10894        use tokio::sync::oneshot;
10895
10896        let params = small_pix_params();
10897        let quota_per_ssa = pix_params_to_quota(&params);
10898        let pix_toolbox = pix_toolbox_with_pool(
10899            SsaShareGenerator::new(SsaGeneratorConfig {
10900                polynomials_per_ssa: 2,
10901                threshold: 2,
10902                surplus_shares: TEST_SURPLUS_SHARES,
10903            })
10904            .into(),
10905            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
10906        );
10907
10908        let mgr = SessionManager::new(SessionManagerConfig {
10909            pix_config: IncomingSessionPixConfig {
10910                quota_range: 2 * quota_per_ssa..=2 * quota_per_ssa,
10911                ..Default::default()
10912            },
10913            ..Default::default()
10914        });
10915
10916        let mut bob_transport = MockMsgSender::new();
10917        let (tx, rx) = oneshot::channel();
10918        let tx = Arc::new(std::sync::Mutex::new(Some(tx)));
10919
10920        bob_transport.expect_send_message().returning(move |_, data| {
10921            let tx = tx.clone();
10922            Box::pin(async move {
10923                if let Ok(HoprStartProtocol::SessionError(err)) =
10924                    HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
10925                    && let Some(tx) = tx.lock().unwrap().take()
10926                {
10927                    let _ = tx.send(err);
10928                }
10929                Ok(())
10930            })
10931        });
10932
10933        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
10934        let (new_session_tx, _) = futures::channel::mpsc::channel(1);
10935        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
10936
10937        let alice_pseudonym = HoprPseudonym::random();
10938
10939        mgr.handle_incoming_session_initiation(
10940            alice_pseudonym,
10941            StartInitiation {
10942                challenge: MIN_CHALLENGE,
10943                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10944                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
10945                additional_data: into_initiation_additional_data(params.into_additional_data(0)),
10946            },
10947        )
10948        .await?;
10949
10950        let err = rx.await.context("send_message was never called")?;
10951        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
10952        assert_eq!(err.identifier, ErrorIdentifier::Challenge(MIN_CHALLENGE));
10953        assert_eq!(mgr.num_active_sessions(), 0);
10954
10955        bob_sender.close_channel();
10956        bob_handle.await??;
10957        Ok(())
10958    }
10959
10960    /// Verifies that an incoming session initiation that does not declare `UsePIX` capability is
10961    /// rejected when PIX is enforced on the responder.
10962    ///
10963    /// ## Steps
10964    /// 1. Bob's manager is configured with `pix_config.enforce_pix: true`, requiring all incoming sessions to opt into
10965    ///    PIX.
10966    /// 2. The incoming initiation carries `Capability::Segmentation` only (no `UsePIX`).
10967    /// 3. `handle_incoming_session_initiation` is called; Bob's manager detects the missing `UsePIX` capability and
10968    ///    sends a `SessionError` with `UnacceptablePixParams`.
10969    /// 4. The test receives the error and asserts `err.reason == UnacceptablePixParams`.
10970    /// 5. `num_active_sessions` is 0, confirming no session slot was created.
10971    #[test_log::test(tokio::test)]
10972    async fn incoming_session_without_usepix_is_rejected_when_pix_enforced() -> anyhow::Result<()> {
10973        use std::sync::Arc;
10974
10975        use hopr_protocol_start::{StartErrorReason, StartInitiation};
10976        use tokio::sync::oneshot;
10977
10978        let mgr = SessionManager::new(SessionManagerConfig {
10979            pix_config: IncomingSessionPixConfig {
10980                enforce_pix: true,
10981                ..Default::default()
10982            },
10983            ..Default::default()
10984        });
10985
10986        let mut bob_transport = MockMsgSender::new();
10987        let (tx, rx) = oneshot::channel();
10988        let tx = Arc::new(std::sync::Mutex::new(Some(tx)));
10989
10990        bob_transport.expect_send_message().returning(move |_, data| {
10991            let tx = tx.clone();
10992            Box::pin(async move {
10993                if let Ok(HoprStartProtocol::SessionError(err)) =
10994                    HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
10995                    && let Some(tx) = tx.lock().unwrap().take()
10996                {
10997                    let _ = tx.send(err);
10998                }
10999                Ok(())
11000            })
11001        });
11002
11003        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11004        let (new_session_tx, _) = futures::channel::mpsc::channel(1);
11005        mgr.start(bob_sender.clone(), new_session_tx, None, None)?;
11006
11007        let alice_pseudonym = HoprPseudonym::random();
11008
11009        mgr.handle_incoming_session_initiation(
11010            alice_pseudonym,
11011            StartInitiation {
11012                challenge: MIN_CHALLENGE,
11013                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11014                capabilities: HoprSessionCapabilities(Capability::Segmentation.into()),
11015                additional_data: 0,
11016            },
11017        )
11018        .await?;
11019
11020        let err = rx.await.context("send_message was never called")?;
11021        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
11022        assert_eq!(err.identifier, ErrorIdentifier::Challenge(MIN_CHALLENGE));
11023        assert_eq!(mgr.num_active_sessions(), 0);
11024
11025        bob_sender.close_channel();
11026        bob_handle.await??;
11027        Ok(())
11028    }
11029
11030    /// A node with no `PixToolbox` must refuse an incoming session that asks for `UsePIX`, rather
11031    /// than establish one it cannot run the PIX state machine for.
11032    ///
11033    /// This is the guard at the top of `handle_incoming_session_initiation`, and it was untested:
11034    /// the integration test named for it never negotiated PIX at all, so the absent toolbox was not
11035    /// the operative cause of anything it observed.
11036    ///
11037    /// The offered dimensions have to be ones `check_pix_params` *accepts*, and the test asserts
11038    /// that before exercising the handler. The fallthrough from a rejected `check_pix_params` emits
11039    /// the identical `StartErrorReason::UnacceptablePixParams` under the identical
11040    /// `ErrorIdentifier::Challenge` and creates no session either, so with unacceptable dimensions
11041    /// nothing here could tell the two refusals apart — the guard could be deleted outright and
11042    /// this would still pass.
11043    #[test_log::test(tokio::test)]
11044    async fn incoming_usepix_session_is_rejected_when_no_pix_toolbox_is_installed() -> anyhow::Result<()> {
11045        use std::sync::Arc;
11046
11047        use hopr_protocol_start::{StartErrorReason, StartInitiation};
11048        use tokio::sync::oneshot;
11049
11050        // PIX not enforced, and the default `quota_range` ends exactly on the quota the default
11051        // dimensions imply, so the offer below sits inside it.
11052        let mgr = SessionManager::new(SessionManagerConfig::default());
11053
11054        let mut bob_transport = MockMsgSender::new();
11055        let (tx, rx) = oneshot::channel();
11056        let tx = Arc::new(std::sync::Mutex::new(Some(tx)));
11057
11058        bob_transport.expect_send_message().returning(move |_, data| {
11059            let tx = tx.clone();
11060            Box::pin(async move {
11061                if let Ok(HoprStartProtocol::SessionError(err)) =
11062                    HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
11063                    && let Some(tx) = tx.lock().unwrap().take()
11064                {
11065                    let _ = tx.send(err);
11066                }
11067                Ok(())
11068            })
11069        });
11070
11071        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11072        let (new_session_tx, _) = futures::channel::mpsc::channel(1);
11073        // No toolbox — the third argument is what a relay that does not participate in PIX gets.
11074        mgr.start(bob_sender.clone(), new_session_tx, None, None)?;
11075
11076        let req = StartInitiation {
11077            challenge: MIN_CHALLENGE,
11078            target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11079            capabilities: HoprSessionCapabilities(Capability::Segmentation | Capability::UsePIX),
11080            additional_data: into_initiation_additional_data(DEFAULT_PIX_PARAMS.into_additional_data(0)),
11081        };
11082
11083        // What makes the missing toolbox the sole remaining cause of the refusal below.
11084        assert!(
11085            decode_and_check(&mgr, &req).is_some(),
11086            "the offered dimensions must be acceptable, or the refusal cannot be attributed to the guard"
11087        );
11088
11089        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), req)
11090            .await?;
11091
11092        let err = rx.await.context("send_message was never called")?;
11093        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
11094        assert_eq!(err.identifier, ErrorIdentifier::Challenge(MIN_CHALLENGE));
11095        assert_eq!(mgr.num_active_sessions(), 0, "no slot may be created for a refusal");
11096
11097        bob_sender.close_channel();
11098        bob_handle.await??;
11099        Ok(())
11100    }
11101
11102    /// Verifies that the exit/responder (Bob) rejects an `SsaCommit` for a session that has no PIX
11103    /// state — i.e., the SSA commit is delivered with a session ID that Bob does not hold.
11104    ///
11105    /// ## Steps
11106    /// 1. Bob's manager is started with a `PixToolbox` and a PIX quota config. Alice's session initiation is processed
11107    ///    normally via `handle_incoming_session_initiation`, establishing a session with PIX state.
11108    /// 2. `handle_ssa_commit` is called with a completely different (random) session ID — one that Bob's manager does
11109    ///    not have.
11110    /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::NonExistingSession)`,
11111    ///    confirming the exit rejects commits for unknown sessions.
11112    #[test_log::test(tokio::test)]
11113    async fn exit_rejects_ssa_commit_when_session_has_no_pix_state() -> anyhow::Result<()> {
11114        use std::collections::HashMap;
11115
11116        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11117        use hopr_protocol_start::StartInitiation;
11118
11119        let ssa_gen_config = SsaGeneratorConfig {
11120            polynomials_per_ssa: 2,
11121            threshold: 2,
11122            surplus_shares: 1,
11123        };
11124        let ssa_rec_config = SsaReconstructorConfig::default();
11125
11126        let (pix_toolbox, _) = PixToolbox::new(
11127            SsaShareGenerator::new(ssa_gen_config).into(),
11128            SsaReconstructor::new(ssa_rec_config).into(),
11129        );
11130
11131        let mgr = SessionManager::new(SessionManagerConfig {
11132            pix_config: IncomingSessionPixConfig {
11133                quota_range: 0..=1024 * 1024 * 1024,
11134                ..Default::default()
11135            },
11136            ..Default::default()
11137        });
11138
11139        let mut bob_transport = MockMsgSender::new();
11140        bob_transport
11141            .expect_send_message()
11142            .returning(|_, _| Box::pin(async { Ok(()) }));
11143        // handle_incoming_session_initiation sends SessionEstablished + SsaRequest.
11144        bob_transport
11145            .expect_send_message()
11146            .returning(|_, _| Box::pin(async { Ok(()) }));
11147
11148        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11149        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11150        let _notifications = tokio::spawn(async move {
11151            pin_mut!(new_session_rx);
11152            while let Some(_session) = new_session_rx.next().await {}
11153        });
11154        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11155
11156        let alice_pseudonym = HoprPseudonym::random();
11157
11158        mgr.handle_incoming_session_initiation(
11159            alice_pseudonym,
11160            StartInitiation {
11161                challenge: MIN_CHALLENGE,
11162                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11163                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11164                additional_data: small_pix_additional_data(),
11165            },
11166        )
11167        .await?;
11168
11169        let result = mgr
11170            .handle_ssa_commit(
11171                HoprPseudonym::random(),
11172                SsaClientCommitmentMessage {
11173                    session_id: alice_pseudonym,
11174                    ssa_index: SsaIndex::MIN,
11175                    coefficient_index: 0,
11176                    commitment_proof: None,
11177                    coefficient_commitments: HashMap::new(),
11178                },
11179            )
11180            .await;
11181
11182        bob_sender.close_channel();
11183        bob_handle.await??;
11184
11185        assert!(result.is_err());
11186        assert!(matches!(
11187            result.unwrap_err(),
11188            TransportSessionError::Manager(SessionManagerError::NonExistingSession)
11189        ));
11190
11191        Ok(())
11192    }
11193
11194    /// Verifies that a session is closed on the very first `UnverifiableShares` PIX event.
11195    ///
11196    /// An event means a whole polynomial failed to open its commitment, which already dooms the
11197    /// cycle, so there is nothing to tolerate and nothing to configure. See
11198    /// `SessionPixSupervisor::on_unverifiable_shares` for the reasoning.
11199    ///
11200    /// ## Steps
11201    /// 1. Bob's manager is started with a `PixToolbox` and a PIX quota config. Alice's session initiation is processed
11202    ///    via `handle_incoming_session_initiation`.
11203    /// 2. One `UnverifiableShares` event is dispatched for the session's `SsaId`.
11204    /// 3. The session is closed: `active_sessions` is empty and `num_active_sessions` is 0, confirming the supervisor
11205    ///    closed it.
11206    #[test_log::test(tokio::test)]
11207    async fn session_is_closed_on_the_first_unverifiable_share() -> anyhow::Result<()> {
11208        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11209        use hopr_protocol_start::StartInitiation;
11210
11211        let ssa_gen_config = SsaGeneratorConfig {
11212            polynomials_per_ssa: 2,
11213            threshold: 2,
11214            surplus_shares: 1,
11215        };
11216
11217        let (pix_toolbox, _) = PixToolbox::new(
11218            SsaShareGenerator::new(ssa_gen_config).into(),
11219            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11220        );
11221
11222        let mgr = SessionManager::new(SessionManagerConfig {
11223            pix_config: IncomingSessionPixConfig {
11224                quota_range: 0..=1024 * 1024 * 1024,
11225                ..Default::default()
11226            },
11227            ..Default::default()
11228        });
11229
11230        let mut bob_transport = MockMsgSender::new();
11231        bob_transport
11232            .expect_send_message()
11233            .returning(|_, _| Box::pin(async { Ok(()) }));
11234        // handle_incoming_session_initiation sends SessionEstablished + SsaRequest.
11235        bob_transport
11236            .expect_send_message()
11237            .returning(|_, _| Box::pin(async { Ok(()) }));
11238
11239        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11240        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11241        let _notifications = tokio::spawn(async move {
11242            pin_mut!(new_session_rx);
11243            while let Some(_session) = new_session_rx.next().await {}
11244        });
11245        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11246
11247        let alice_pseudonym = HoprPseudonym::random();
11248
11249        mgr.handle_incoming_session_initiation(
11250            alice_pseudonym,
11251            StartInitiation {
11252                challenge: MIN_CHALLENGE,
11253                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11254                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11255                additional_data: small_pix_additional_data(),
11256            },
11257        )
11258        .await?;
11259
11260        assert_eq!(mgr.num_active_sessions(), 1, "the session must start out open");
11261
11262        let ssa_id = SsaId::new(alice_pseudonym, SsaIndex::MIN);
11263        mgr.dispatch_pix_event(HoprSessionInPixEvent::UnverifiableShares {
11264            ssa_id,
11265            observed_total: 1,
11266            peer: crate::supervision::test_peer(),
11267        })
11268        .await?;
11269
11270        // The supervisor decides, and its `Close` reaches the driver over a channel, so the close
11271        // is observed rather than assumed.
11272        tokio::time::timeout(Duration::from_secs(1), async {
11273            while mgr.num_active_sessions() > 0 {
11274                tokio::time::sleep(Duration::from_millis(10)).await;
11275            }
11276        })
11277        .await
11278        .context("session must be closed by the first unverifiable share")?;
11279
11280        assert!(mgr.active_sessions().is_empty());
11281
11282        bob_sender.close_channel();
11283        bob_handle.await??;
11284        Ok(())
11285    }
11286
11287    // Four tests lived here, all asserting how many `SsaRequest` messages an `SsaAlmostRecovered` or
11288    // `SsaRecovered` event produced: `exit_requests_new_ssa_after_almost_recovered_event`,
11289    // `exit_ignores_stale_ssa_almost_recovered_event`,
11290    // `exit_handles_concurrent_almost_and_full_recovery_for_same_ssa` and
11291    // `exit_requests_new_ssa_on_recovery_when_not_already_pipelined`.
11292    //
11293    // That decision is the supervisor's now, and each of them has a direct counterpart against the
11294    // state machine: `almost_recovered_while_recovering_requests_next_once`,
11295    // `almost_recovered_while_awaiting_deposit_defers_request`,
11296    // `recovered_with_prior_early_event_does_not_fallback` and
11297    // `recovered_without_prior_early_event_falls_back_to_request`. Reaching the same logic through
11298    // an event channel, an action channel and a spawned driver only added scheduling noise — and
11299    // three of the four asserted a pipelining that no longer happens on an unfunded SSA, which is
11300    // the deliberate change: the Exit does not commit to another SSA before the current one is paid
11301    // for.
11302    //
11303    // What those tests did cover that the state machine cannot is the wiring itself, in both
11304    // directions. Actions reaching the wire is `the_opening_ssa_request_follows_session_established`
11305    // below; events reaching the supervisor is `session_is_closed_on_the_first_unverifiable_share`.
11306
11307    /// Verifies that the supervisor's opening `RequestSsa` reaches the wire, and does so *after* the
11308    /// `SessionEstablished` that publishes the Session.
11309    ///
11310    /// The ordering is why the supervisor is spawned where it is. An `SsaRequest` arriving first
11311    /// would reference a Session the Entry has not been told exists.
11312    #[test_log::test(tokio::test)]
11313    async fn the_opening_ssa_request_follows_session_established() -> anyhow::Result<()> {
11314        use std::sync::Arc;
11315
11316        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11317        use hopr_protocol_start::StartInitiation;
11318
11319        let pix_toolbox = pix_toolbox_with_pool(
11320            SsaShareGenerator::new(SsaGeneratorConfig {
11321                polynomials_per_ssa: 2,
11322                threshold: 2,
11323                surplus_shares: 1,
11324            })
11325            .into(),
11326            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11327        );
11328
11329        let mgr = SessionManager::new(SessionManagerConfig {
11330            pix_config: IncomingSessionPixConfig {
11331                quota_range: 0..=1024 * 1024 * 1024,
11332                ..Default::default()
11333            },
11334            ..Default::default()
11335        });
11336
11337        // Records the kind of every outbound Start message, in order.
11338        let sent = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new()));
11339        let sent_clone = sent.clone();
11340        let mut bob_transport = MockMsgSender::new();
11341        bob_transport.expect_send_message().returning(move |_, data| {
11342            let sent_clone = sent_clone.clone();
11343            Box::pin(async move {
11344                match HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text) {
11345                    Ok(HoprStartProtocol::SessionEstablished(_)) => sent_clone.lock().unwrap().push("established"),
11346                    Ok(HoprStartProtocol::SsaRequest(req)) => {
11347                        assert_eq!(
11348                            req.dimensions().expect("SsaRequest params must be in range"),
11349                            PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE).expect("valid"),
11350                            "SsaRequest must carry the negotiated dimensions, surplus included"
11351                        );
11352                        assert_eq!(
11353                            req.commitments.keys().copied().collect::<Vec<_>>(),
11354                            [SsaIndex::MIN],
11355                            "the opening request must commit to the first SSA index"
11356                        );
11357                        sent_clone.lock().unwrap().push("ssa_request");
11358                    }
11359                    _ => {}
11360                }
11361                Ok(())
11362            })
11363        });
11364
11365        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11366        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11367        let _notifications = tokio::spawn(async move {
11368            pin_mut!(new_session_rx);
11369            while let Some(_session) = new_session_rx.next().await {}
11370        });
11371        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11372
11373        let alice_pseudonym = HoprPseudonym::random();
11374        mgr.handle_incoming_session_initiation(
11375            alice_pseudonym,
11376            StartInitiation {
11377                challenge: MIN_CHALLENGE,
11378                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11379                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11380                additional_data: small_pix_additional_data(),
11381            },
11382        )
11383        .await?;
11384
11385        tokio::time::timeout(Duration::from_secs(1), async {
11386            while sent.lock().unwrap().len() < 2 {
11387                tokio::time::sleep(Duration::from_millis(10)).await;
11388            }
11389        })
11390        .await
11391        .context("the supervisor's opening SsaRequest never reached the wire")?;
11392
11393        assert_eq!(
11394            sent.lock().unwrap().as_slice(),
11395            ["established", "ssa_request"],
11396            "the Entry must learn the Session exists before it is asked to commit to an SSA"
11397        );
11398
11399        bob_sender.close_channel();
11400        bob_handle.await??;
11401        Ok(())
11402    }
11403
11404    /// A pool that does not supply deposit data must take the Session with it, and name itself.
11405    ///
11406    /// The data only ever travels in the `SsaRequest` that carries the commitments, so an Entry that
11407    /// needs it and does not get it cannot ask for it later — it would simply never deposit, and the
11408    /// Session would die on the Exit's own commitment deadline, blamed on the peer. Failing here
11409    /// costs one Session that was not going to work, and points at the local pool instead: the Entry
11410    /// is told with `UnacceptablePixParams` rather than the supervisor's generic `Unknown`, and this
11411    /// node records [`ClosureReason::MissingDepositData`] rather than `PixFailure`.
11412    ///
11413    /// The event stream is dropped rather than left unanswered, which reaches the same failure
11414    /// through the listener check instead of [`DEPOSIT_DATA_REQUEST_TIMEOUT`] — the same error, the
11415    /// same close, three seconds sooner. What the collection itself does with a pool that answers
11416    /// badly is covered by the `request_deposit_data` tests below.
11417    #[test_log::test(tokio::test)]
11418    async fn missing_deposit_data_closes_the_session_and_names_the_pool() -> anyhow::Result<()> {
11419        use std::sync::Arc;
11420
11421        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11422        use hopr_protocol_start::StartInitiation;
11423
11424        // Deliberately *not* `pix_toolbox_with_pool`: nothing answers, which is the case under test.
11425        let (pix_toolbox, pix_events) = PixToolbox::new(
11426            SsaShareGenerator::new(SsaGeneratorConfig {
11427                polynomials_per_ssa: 2,
11428                threshold: 2,
11429                surplus_shares: 1,
11430            })
11431            .into(),
11432            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11433        );
11434        drop(pix_events);
11435
11436        let mgr = SessionManager::new(SessionManagerConfig {
11437            pix_config: IncomingSessionPixConfig {
11438                quota_range: 0..=1024 * 1024 * 1024,
11439                ..Default::default()
11440            },
11441            ..Default::default()
11442        });
11443
11444        // Records the kind of every outbound Start message, with the reason of any SessionError.
11445        let sent = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
11446        let sent_clone = sent.clone();
11447        let mut bob_transport = MockMsgSender::new();
11448        bob_transport.expect_send_message().returning(move |_, data| {
11449            let sent_clone = sent_clone.clone();
11450            Box::pin(async move {
11451                match HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text) {
11452                    Ok(HoprStartProtocol::SessionEstablished(_)) => {
11453                        sent_clone.lock().unwrap().push("established".into())
11454                    }
11455                    Ok(HoprStartProtocol::SsaRequest(_)) => sent_clone.lock().unwrap().push("ssa_request".into()),
11456                    Ok(HoprStartProtocol::SessionError(err)) => {
11457                        sent_clone.lock().unwrap().push(format!("error:{}", err.reason))
11458                    }
11459                    _ => {}
11460                }
11461                Ok(())
11462            })
11463        });
11464
11465        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11466        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11467        let _notifications = tokio::spawn(async move {
11468            pin_mut!(new_session_rx);
11469            while let Some(_session) = new_session_rx.next().await {}
11470        });
11471        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11472
11473        let alice_pseudonym = HoprPseudonym::random();
11474        mgr.handle_incoming_session_initiation(
11475            alice_pseudonym,
11476            StartInitiation {
11477                challenge: MIN_CHALLENGE,
11478                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11479                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11480                additional_data: small_pix_additional_data(),
11481            },
11482        )
11483        .await?;
11484
11485        timeout(Duration::from_secs(1), async {
11486            while mgr.num_active_sessions() > 0 {
11487                tokio::time::sleep(Duration::from_millis(10)).await;
11488            }
11489        })
11490        .await
11491        .context("the Session must not survive a pool that cannot supply deposit data")?;
11492
11493        let sent = sent.lock().unwrap().clone();
11494        assert!(
11495            !sent.iter().any(|m| m == "ssa_request"),
11496            "no SsaRequest may go on the wire without the deposit data it has to carry, got {sent:?}"
11497        );
11498        assert!(
11499            sent.iter()
11500                .any(|m| m == &format!("error:{}", StartErrorReason::UnacceptablePixParams)),
11501            "the Entry must be told with a reason that names the failure, got {sent:?}"
11502        );
11503
11504        bob_sender.close_channel();
11505        bob_handle.await??;
11506        Ok(())
11507    }
11508
11509    // `pipelined_ssa_preserves_earlier_deposit_deadline` lived here. It asserted that
11510    // `PixKillSwitch(1)` and `PixKillSwitch(2)` were independent entries in the `AbortableList`,
11511    // which is no longer a thing the manager can observe: per-SSA deadlines belong to the
11512    // supervisor. The invariant moved with them, to
11513    // `supervision::supervisor::tests::pipelining_a_second_ssa_leaves_the_first_ones_deadlines_alone`,
11514    // where it can compare the deadline instants rather than just the presence of two handles.
11515
11516    /// Verifies that an explicit `close_session` leaves no reconstructor state behind for an SSA
11517    /// cycle that was still in flight.
11518    ///
11519    /// Retirement used to be an explicit sweep over every index the Session had ever used; it is
11520    /// now a consequence of aborting the action driver, which drops the guards it holds. This
11521    /// exercises one cycle because that is all a mock transport can get in flight — pipelining a
11522    /// second needs a funded first, and funding arrives from the chain. The multi-cycle case is
11523    /// covered where the guards are: `protocols/pix`'s guard tests and the supervisor's own suite.
11524    #[test_log::test(tokio::test)]
11525    async fn close_session_retires_in_flight_ssa_cycles() -> anyhow::Result<()> {
11526        let pix_toolbox = pix_toolbox_with_pool(
11527            SsaShareGenerator::new(SsaGeneratorConfig {
11528                polynomials_per_ssa: 2,
11529                threshold: 2,
11530                surplus_shares: 1,
11531            })
11532            .into(),
11533            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11534        );
11535        let mgr = SessionManager::new(SessionManagerConfig {
11536            pix_config: IncomingSessionPixConfig {
11537                quota_range: 0..=1024 * 1024 * 1024,
11538                ..Default::default()
11539            },
11540            ..Default::default()
11541        });
11542        let mut bob_transport = MockMsgSender::new();
11543        bob_transport
11544            .expect_send_message()
11545            .returning(|_, _| Box::pin(async { Ok(()) }));
11546        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11547        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11548        let _notifications = tokio::spawn(async move {
11549            pin_mut!(new_session_rx);
11550            while let Some(_session) = new_session_rx.next().await {}
11551        });
11552        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11553
11554        let alice_pseudonym = HoprPseudonym::random();
11555        mgr.handle_incoming_session_initiation(
11556            alice_pseudonym,
11557            StartInitiation {
11558                challenge: MIN_CHALLENGE,
11559                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11560                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11561                additional_data: small_pix_additional_data(),
11562            },
11563        )
11564        .await?;
11565
11566        // Grab a reference to the reconstructor before close_session consumes the slot.
11567        let pix_toolbox_ref = mgr.pix_toolbox.get().unwrap().clone();
11568        let share_processor = pix_toolbox_ref.share_processor;
11569
11570        let ssa1 = SsaId::new(alice_pseudonym, SsaIndex::MIN);
11571
11572        // Precondition: the first cycle's builder exists, so the assertion below proves actual
11573        // retirement rather than the absence of a builder that was never created. The supervisor's
11574        // opening `RequestSsa` reaches the driver asynchronously, so this waits rather than assumes.
11575        tokio::time::timeout(Duration::from_secs(1), async {
11576            while !share_processor.contains_builder(&ssa1) {
11577                tokio::time::sleep(Duration::from_millis(10)).await;
11578            }
11579        })
11580        .await
11581        .context("the opening SSA request never registered a builder")?;
11582
11583        mgr.close_session(&alice_pseudonym);
11584
11585        // Aborting the driver drops the `SsaCommitmentGuard`s it owns, which releases each SSA's
11586        // reconstructor state. The abort takes effect at the next scheduling point, hence the wait.
11587        tokio::time::timeout(Duration::from_secs(1), async {
11588            while share_processor.contains_builder(&ssa1) {
11589                tokio::time::sleep(Duration::from_millis(10)).await;
11590            }
11591        })
11592        .await
11593        .context("close_session left the in-flight SSA's reconstructor state behind")?;
11594
11595        bob_sender.close_channel();
11596        bob_handle.await??;
11597        Ok(())
11598    }
11599
11600    /// Verifies that the entry/initiator (Alice) rejects a `SsaRequest` from the exit when the
11601    /// proposed SSA quota does not match what Alice announced from her own installed generator.
11602    ///
11603    /// ## Steps
11604    /// 1. Bob's manager is started with a `PixToolbox` and a generous PIX quota config. Alice's session initiation is
11605    ///    processed with `additional_data = (polynomials=2, shares=2)`.
11606    /// 2. `handle_ssa_request` is called with a mismatched quota: `(server_polynomials=10, server_shares=10)` while
11607    ///    Alice offered `(2, 2)`.
11608    /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::Unacceptable(_))`,
11609    ///    confirming the quota mismatch was detected and rejected.
11610    #[test_log::test(tokio::test)]
11611    async fn entry_rejects_ssa_request_with_mismatched_quota() -> anyhow::Result<()> {
11612        use std::collections::BTreeMap;
11613
11614        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11615        use hopr_protocol_start::StartInitiation;
11616
11617        let ssa_gen_config = SsaGeneratorConfig {
11618            polynomials_per_ssa: 2,
11619            threshold: 2,
11620            surplus_shares: 1,
11621        };
11622
11623        let (pix_toolbox, _) = PixToolbox::new(
11624            SsaShareGenerator::new(ssa_gen_config).into(),
11625            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11626        );
11627
11628        let mgr = SessionManager::new(SessionManagerConfig {
11629            pix_config: IncomingSessionPixConfig {
11630                quota_range: 0..=1024 * 1024 * 1024,
11631                ..Default::default()
11632            },
11633            ..Default::default()
11634        });
11635
11636        let mut bob_transport = MockMsgSender::new();
11637        bob_transport
11638            .expect_send_message()
11639            .returning(|_, _| Box::pin(async { Ok(()) }));
11640        // handle_incoming_session_initiation sends SessionEstablished + SsaRequest.
11641        bob_transport
11642            .expect_send_message()
11643            .returning(|_, _| Box::pin(async { Ok(()) }));
11644
11645        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11646        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11647        let _notifications = tokio::spawn(async move {
11648            pin_mut!(new_session_rx);
11649            while let Some(_session) = new_session_rx.next().await {}
11650        });
11651        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11652
11653        let alice_pseudonym = HoprPseudonym::random();
11654
11655        mgr.handle_incoming_session_initiation(
11656            alice_pseudonym,
11657            StartInitiation {
11658                challenge: MIN_CHALLENGE,
11659                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11660                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11661                additional_data: small_pix_additional_data(),
11662            },
11663        )
11664        .await?;
11665
11666        let session_id = alice_pseudonym;
11667
11668        // Server sends dimensions of (10, 10) while we offered (2, 2) — should be rejected.
11669        let result = mgr
11670            .handle_ssa_request(
11671                alice_pseudonym,
11672                SsaServerCommitmentMessage::new(
11673                    session_id,
11674                    PixParams::try_new(10, 10, 0, LOCAL_PIX_SUITE)?,
11675                    BTreeMap::new(),
11676                    [],
11677                ),
11678            )
11679            .await;
11680
11681        bob_sender.close_channel();
11682        bob_handle.await??;
11683
11684        assert!(result.is_err());
11685        assert!(matches!(
11686            result.unwrap_err(),
11687            TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
11688        ));
11689
11690        Ok(())
11691    }
11692
11693    /// The Entry must not admit a successor before a default Exit could possibly have produced its
11694    /// early-recovery request.
11695    ///
11696    /// Recovery is counted in completed polynomials, while emission is windowed. Every fully drained
11697    /// window before the one containing the 85% boundary has already emitted both threshold and
11698    /// surplus shares. Dividing 85% by the global 1.25x surplus factor therefore underestimates the
11699    /// boundary: at the deployed dimensions the first possible early signal is about 87% through
11700    /// emission, not 68%.
11701    ///
11702    /// The gate compares against `min_emission_for_early_recovery` directly, so this test recomputes
11703    /// the boundary independently — from the same first principles, but without calling the function
11704    /// under test — and checks the two agree. A shared helper would agree with itself.
11705    #[test]
11706    fn successor_gate_waits_until_default_early_recovery_can_be_reached() {
11707        let params = DEFAULT_PIX_PARAMS;
11708        let polys = params.polys_per_ssa() as u64;
11709        let threshold = params.shares_per_poly() as u64;
11710        let surplus = params.surplus_shares() as u64;
11711        let window = (hopr_protocol_pix::SHARE_EMISSION_WINDOW as u64).min(polys);
11712        // The floor, because that is what the gate is computed at — see
11713        // `MIN_EARLY_RECOVERY_THRESHOLD`. The default must not sit below it, or a stock Exit would
11714        // ask before a stock Entry admits.
11715        let early = hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD;
11716        assert!(
11717            SsaReconstructorConfig::default().early_recovery_threshold >= early,
11718            "the shipped default must be admissible under the protocol floor it is gated against"
11719        );
11720        let needed = (early * polys as f64).ceil() as u64;
11721
11722        // `needed` lies in this window. Earlier windows are fully exhausted, including surplus;
11723        // inside this one, `threshold - 1` complete passes and `in_window` shares of the threshold
11724        // pass are the absolute minimum that can have been emitted before those polynomials recover.
11725        let prior_windows = (needed - 1) / window;
11726        let prior_polys = prior_windows * window;
11727        let current_width = window.min(polys - prior_polys);
11728        let in_window = needed - prior_polys;
11729        let minimum_emitted = prior_polys * (threshold + surplus) + (threshold - 1) * current_width + in_window;
11730        let minimum_fraction = minimum_emitted as f64 / (polys * (threshold + surplus)) as f64;
11731
11732        let gate = hopr_protocol_pix::min_emission_for_early_recovery(&params, early);
11733        assert_eq!(
11734            minimum_emitted,
11735            gate,
11736            "the successor gate opens at {} shares ({:.1}% of emission) against an independently derived earliest \
11737             honest request of {minimum_emitted} ({:.1}%); any shortfall permits a hostile Exit to solicit a deposit \
11738             before it is earned",
11739            gate,
11740            gate as f64 / (polys * (threshold + surplus)) as f64 * 100.0,
11741            minimum_fraction * 100.0,
11742        );
11743
11744        // And the estimate this replaced is demonstrably below it, so the test would have caught it.
11745        assert!(
11746            ((early / 1.5) * (polys * (threshold + surplus)) as f64) < gate as f64,
11747            "the surplus-factor estimate must sit below the real boundary"
11748        );
11749    }
11750
11751    /// Dimensions wide enough that the protocol floor and a stricter local threshold disagree.
11752    ///
11753    /// At `small_pix_params`' two polynomials, `ceil(0.85 x 2)` and `ceil(1.0 x 2)` are both 2, so
11754    /// every threshold in range produces the same boundary and a test built on them cannot tell which
11755    /// one the gate used. Eight separates them: 7 polynomials against 8.
11756    fn wide_pix_params() -> PixParams {
11757        PixParams::try_new(8, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE).expect("test dimensions must be valid")
11758    }
11759
11760    /// Builds an Entry-side manager serving `params`, with the reconstructor at `early_threshold`.
11761    ///
11762    /// Returns the manager, its generator, and the PIX event stream, with one PIX Session already
11763    /// established for the returned pseudonym.
11764    #[allow(clippy::type_complexity)]
11765    async fn entry_with_pix_session(
11766        params: PixParams,
11767        early_threshold: f64,
11768    ) -> anyhow::Result<(
11769        SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>,
11770        Arc<SsaShareGenerator<HoprPixSpec>>,
11771        HoprPseudonym,
11772        impl futures::Stream<Item = HoprSessionOutPixEvent> + Unpin,
11773        UnboundedSender<(DestinationRouting, ApplicationDataOut)>,
11774        tokio::task::JoinHandle<anyhow::Result<()>>,
11775    )> {
11776        use hopr_protocol_start::StartInitiation;
11777
11778        let generator = Arc::new(SsaShareGenerator::new(SsaGeneratorConfig {
11779            polynomials_per_ssa: params.polys_per_ssa(),
11780            threshold: params.shares_per_poly(),
11781            surplus_shares: params.surplus_shares(),
11782        }));
11783        let (pix_toolbox, pix_events) = PixToolbox::new(
11784            generator.clone(),
11785            SsaReconstructor::new(SsaReconstructorConfig {
11786                early_recovery_threshold: early_threshold,
11787                ..Default::default()
11788            })
11789            .into(),
11790        );
11791        // This fixture's manager plays both halves, so its own Exit side opens a cycle and blocks on
11792        // the deposit pool. Answered on the way through, leaving the stream the caller reads — which
11793        // is the deposits — as it was.
11794        let pix_events = crate::testing::answering_deposit_pool(pix_events, |_| Vec::new());
11795
11796        let mgr = SessionManager::new(SessionManagerConfig {
11797            pix_config: IncomingSessionPixConfig {
11798                quota_range: 0..=1024 * 1024 * 1024,
11799                ..Default::default()
11800            },
11801            ..Default::default()
11802        });
11803
11804        let mut bob_transport = MockMsgSender::new();
11805        bob_transport
11806            .expect_send_message()
11807            .returning(|_, _| Box::pin(async { Ok(()) }));
11808        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11809        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11810        tokio::spawn(async move {
11811            pin_mut!(new_session_rx);
11812            while let Some(_session) = new_session_rx.next().await {}
11813        });
11814        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11815
11816        let pseudonym = HoprPseudonym::random();
11817        mgr.handle_incoming_session_initiation(
11818            pseudonym,
11819            StartInitiation {
11820                challenge: MIN_CHALLENGE,
11821                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11822                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11823                additional_data: into_initiation_additional_data(params.into_additional_data(0)),
11824            },
11825        )
11826        .await?;
11827
11828        Ok((mgr, generator, pseudonym, pix_events, bob_sender, bob_handle))
11829    }
11830
11831    /// One SSA commitment at `index`, from the identity element the Exit-side point is not checked
11832    /// against here.
11833    fn ssa_request_for(index: u32) -> std::collections::BTreeMap<SsaIndex, HoprPixGroupElement> {
11834        let identity = HoprPixGroupElement::try_from(
11835            hopr_protocol_pix::PixGroup::<HoprPixSpec>::default()
11836                .to_bytes()
11837                .as_ref(),
11838        )
11839        .expect("identity element must be valid");
11840        std::collections::BTreeMap::from([(SsaIndex::new(index).expect("non-zero"), identity)])
11841    }
11842
11843    /// Drains whatever the PIX event stream has ready, counting the deposits among it.
11844    ///
11845    /// Only [`ReadyToDeposit`](HoprSessionOutPixEvent::ReadyToDeposit) is counted: an Exit sharing
11846    /// this stream also asks its pool for deposit data on it, and every caller here is asking how
11847    /// many cycles were opened, not how many messages went by.
11848    async fn drain_pix_events(events: &mut (dyn futures::Stream<Item = HoprSessionOutPixEvent> + Unpin)) -> usize {
11849        let mut seen = 0;
11850        // A bounded wait rather than `now_or_never`: these fixtures answer the Exit half's
11851        // deposit-data requests on a forwarding task, so an event that has already been produced may
11852        // not have crossed it yet. The wait is only paid once, when the stream has gone quiet.
11853        while let Ok(Some(event)) = timeout(Duration::from_millis(50), events.next()).await {
11854            if matches!(event, HoprSessionOutPixEvent::ReadyToDeposit(_)) {
11855                seen += 1;
11856            }
11857        }
11858        seen
11859    }
11860
11861    /// Credits `count` Exit → Entry packets to the Session, as its receive path would.
11862    ///
11863    /// These fixtures build their slot through `handle_incoming_session_initiation`, whose receive
11864    /// path counts the *outgoing* direction, so the Entry-side counter the successor gate reads has
11865    /// to be driven by hand. `returned_packets_are_counted_on_the_entry_receive_path` is what pins
11866    /// that a real Entry Session advances it; here it only has to move.
11867    fn credit_returned_packets<S>(mgr: &SessionManager<S>, pseudonym: &HoprPseudonym, count: u64)
11868    where
11869        S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Clone + Send + Sync + Unpin + 'static,
11870        S::Error: std::error::Error + Send + Sync + 'static,
11871    {
11872        mgr.sessions
11873            .get(pseudonym)
11874            .expect("session must exist")
11875            .returned_packets
11876            .fetch_add(count, std::sync::atomic::Ordering::Relaxed);
11877    }
11878
11879    /// Packets the successor gate requires before it will admit a batch beyond `watermark`.
11880    ///
11881    /// Mirrors `handle_ssa_request`'s arithmetic rather than calling it, so that a change to the
11882    /// boundary has to be restated here — a helper shared with the code under test would agree with
11883    /// itself whatever either of them did.
11884    fn required_returned_packets(params: &PixParams, watermark: u32) -> u64 {
11885        let min_emitted =
11886            hopr_protocol_pix::min_emission_for_early_recovery(params, hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD);
11887        let shares_per_cycle = params.polys_per_ssa() as u64 * params.emitted_shares_per_poly() as u64;
11888        let target = (watermark as u64 - 1) * shares_per_cycle + min_emitted;
11889        (target as u128 * params.shares_per_poly() as u128 / params.emitted_shares_per_poly() as u128) as u64
11890    }
11891
11892    /// The successor gate must open at the protocol floor, not at the local reconstructor's threshold.
11893    ///
11894    /// The value that decides when a correct Exit asks for its next batch is the *peer's*
11895    /// `early_recovery_threshold`, and it does not travel on the wire. Deriving the boundary from the
11896    /// Entry's own copy meant two individually valid configurations could not communicate: an Exit
11897    /// configured lower sent its one-shot request before the gate opened, the Entry dropped it
11898    /// silently, and the Session died on `max_ssa_delivery_time` waiting for a commitment that had
11899    /// been deliberately refused — a failure visible on neither node as a configuration error.
11900    ///
11901    /// So the gate is computed at [`hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD`], which
11902    /// `validate_pix_supervision` holds every Exit to. This fixture puts the local reconstructor at
11903    /// `1.0` — the strictest end of the range — and asks at exactly the floor's boundary. A gate built
11904    /// on the local value wants one more share and refuses.
11905    #[test_log::test(tokio::test)]
11906    async fn successor_gate_opens_at_the_protocol_floor_not_the_local_threshold() -> anyhow::Result<()> {
11907        let params = wide_pix_params();
11908        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
11909            entry_with_pix_session(params, 1.0).await?;
11910
11911        mgr.handle_ssa_request(
11912            pseudonym,
11913            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
11914        )
11915        .await
11916        .context("the opening batch must be accepted")?;
11917        assert_eq!(1, drain_pix_events(&mut pix_events).await);
11918
11919        // Fully served, so that the emission boundary is the only thing this test can be measuring.
11920        credit_returned_packets(&mgr, &pseudonym, required_returned_packets(&params, 1));
11921
11922        let at_floor = hopr_protocol_pix::min_emission_for_early_recovery(
11923            &params,
11924            hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD,
11925        );
11926        let at_local = hopr_protocol_pix::min_emission_for_early_recovery(&params, 1.0);
11927        assert!(
11928            at_floor < at_local,
11929            "the fixture must separate the two boundaries, got {at_floor} and {at_local}"
11930        );
11931
11932        // One share short of the floor's boundary, the request is early under either reading.
11933        for sent in 1..at_floor as u32 {
11934            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
11935        }
11936        let err = mgr
11937            .handle_ssa_request(
11938                pseudonym,
11939                SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
11940            )
11941            .await
11942            .expect_err("a request one share below the boundary must be refused");
11943        assert!(
11944            matches!(
11945                err,
11946                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
11947            ),
11948            "expected Unacceptable, got {err:?}"
11949        );
11950        assert_eq!(0, drain_pix_events(&mut pix_events).await);
11951
11952        // On the boundary itself it is admitted — and would not be if the gate read the local 1.0.
11953        generator.next_share(&pseudonym, &at_floor.to_be_bytes())?;
11954        assert_eq!(
11955            at_floor,
11956            generator
11957                .emission_progress(&pseudonym)
11958                .expect("committed")
11959                .front_emitted,
11960            "the fixture must stand exactly on the floor's boundary"
11961        );
11962        mgr.handle_ssa_request(
11963            pseudonym,
11964            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
11965        )
11966        .await
11967        .context("a request at the protocol floor must be accepted whatever the local threshold is")?;
11968        assert_eq!(1, drain_pix_events(&mut pix_events).await);
11969
11970        bob_sender.close_channel();
11971        bob_handle.await??;
11972        Ok(())
11973    }
11974
11975    /// A successor request arriving after the generator's state was discarded must be refused.
11976    ///
11977    /// The gate only applies where there is emission progress to measure, and absent progress is
11978    /// otherwise the ordinary opening batch. But the generator keeps its per-pseudonym state in a
11979    /// cache with an idle retention refreshed by share *emission*, so a Session kept alive while the
11980    /// Entry emits nothing outlives it — and the successor gate is then not relaxed but deleted, along
11981    /// with the monotonic index the discarded entry carried. An Exit that arranges that farms one
11982    /// unearned deposit per retention period, which is the exposure the gate exists to close.
11983    ///
11984    /// `forget` reaches the same state the retention would, deliberately: an evicted entry and an
11985    /// explicitly dropped one are indistinguishable from the outside, which is precisely why the fact
11986    /// that a batch *was* committed has to be held somewhere that lives as long as the Session.
11987    ///
11988    /// The opening request in this test is the other half of the property — absent state with nothing
11989    /// committed is still admitted, or no PIX Session could ever start.
11990    #[test_log::test(tokio::test)]
11991    async fn an_ssa_request_against_discarded_generator_state_is_refused_and_closes_the_session() -> anyhow::Result<()>
11992    {
11993        let params = small_pix_params();
11994        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
11995            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
11996
11997        mgr.handle_ssa_request(
11998            pseudonym,
11999            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12000        )
12001        .await
12002        .context("the opening batch must be accepted")?;
12003        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12004        assert_eq!(vec![pseudonym], mgr.active_sessions());
12005
12006        generator.forget(&pseudonym);
12007        assert!(
12008            generator.emission_progress(&pseudonym).is_none(),
12009            "the fixture must reproduce the state an idle eviction leaves behind"
12010        );
12011
12012        let err = mgr
12013            .handle_ssa_request(
12014                pseudonym,
12015                SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12016            )
12017            .await
12018            .expect_err("a successor request against discarded generator state must be refused");
12019        assert!(
12020            matches!(
12021                err,
12022                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12023            ),
12024            "expected Unacceptable, got {err:?}"
12025        );
12026        assert_eq!(
12027            0,
12028            drain_pix_events(&mut pix_events).await,
12029            "a refused request must not emit a single ReadyToDeposit"
12030        );
12031        assert!(
12032            mgr.active_sessions().is_empty(),
12033            "the Entry can no longer emit shares for the cycles it committed to, so leaving the Session open only \
12034             makes the Exit wait out its commitment deadline"
12035        );
12036
12037        bob_sender.close_channel();
12038        bob_handle.await??;
12039        Ok(())
12040    }
12041
12042    /// Emission is not service: a successor must be refused until the Exit has returned data.
12043    ///
12044    /// This is H6, and it is the case the emission half of the gate cannot see. `emission_progress`
12045    /// counts shares this node handed to its own packet pipeline — a consumption `create_surb_for_path`
12046    /// does not even roll back when the rest of the packet build fails. An Exit that requests, is
12047    /// funded and then returns nothing still walks that counter forward for as long as the Entry keeps
12048    /// sending, so on its own it prices deposits against work the Entry did to itself.
12049    ///
12050    /// The fixture therefore satisfies the emission half completely and the returned half not at all.
12051    /// Before this gate existed the request below was admitted and a second deposit instruction went
12052    /// out for it.
12053    #[test_log::test(tokio::test)]
12054    async fn entry_refuses_a_successor_the_exit_has_not_paid_for_with_returned_data() -> anyhow::Result<()> {
12055        let params = wide_pix_params();
12056        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12057            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12058
12059        mgr.handle_ssa_request(
12060            pseudonym,
12061            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12062        )
12063        .await
12064        .context("the opening batch must be accepted")?;
12065        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12066
12067        // Emit the whole cycle: the emission half of the gate is now satisfied several times over,
12068        // and it is the only half that would have been consulted before.
12069        for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12070            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12071        }
12072        let progress = generator.emission_progress(&pseudonym).expect("committed");
12073        assert!(
12074            progress.is_serving_last_committed()
12075                && progress.front_emitted
12076                    >= hopr_protocol_pix::min_emission_for_early_recovery(
12077                        &params,
12078                        hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD
12079                    ),
12080            "the fixture must satisfy the emission half outright, or it is not testing the other one"
12081        );
12082
12083        let err = mgr
12084            .handle_ssa_request(
12085                pseudonym,
12086                SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12087            )
12088            .await
12089            .expect_err("a successor must not be funded before the Exit has returned anything");
12090        assert!(
12091            matches!(
12092                err,
12093                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12094            ),
12095            "expected Unacceptable, got {err:?}"
12096        );
12097        assert_eq!(
12098            0,
12099            drain_pix_events(&mut pix_events).await,
12100            "a refused successor must not emit a single ReadyToDeposit"
12101        );
12102        assert_eq!(
12103            vec![pseudonym],
12104            mgr.active_sessions(),
12105            "an under-served request is refused, not fatal — the Exit may still earn the batch it holds"
12106        );
12107
12108        // And the same request is admitted the moment the service exists.
12109        credit_returned_packets(&mgr, &pseudonym, required_returned_packets(&params, 1));
12110        mgr.handle_ssa_request(
12111            pseudonym,
12112            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12113        )
12114        .await
12115        .context("a successor must be admitted once the Exit has returned what it owes")?;
12116        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12117
12118        bob_sender.close_channel();
12119        bob_handle.await??;
12120        Ok(())
12121    }
12122
12123    /// An Exit that has returned nothing is refused immediately, not held.
12124    ///
12125    /// The deferral below the boundary exists for reordering, and it holds a bounded
12126    /// `for_each_concurrent` slot and the per-pseudonym request lock while it runs. That is only safe
12127    /// because it is entered exclusively for a *near miss*: an Exit which has returned nothing at all
12128    /// must cost nothing at all to refuse, or "ask early and say nothing" becomes a way to park one of
12129    /// this node's Start-protocol slots per Session it holds.
12130    #[test_log::test(tokio::test)]
12131    async fn an_unserved_successor_is_refused_without_waiting() -> anyhow::Result<()> {
12132        let params = wide_pix_params();
12133        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12134            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12135
12136        mgr.handle_ssa_request(
12137            pseudonym,
12138            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12139        )
12140        .await
12141        .context("the opening batch must be accepted")?;
12142        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12143
12144        for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12145            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12146        }
12147
12148        let started = std::time::Instant::now();
12149        let err = mgr
12150            .handle_ssa_request(
12151                pseudonym,
12152                SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12153            )
12154            .await
12155            .expect_err("an unserved successor must be refused");
12156        let elapsed = started.elapsed();
12157
12158        assert!(
12159            matches!(
12160                err,
12161                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12162            ),
12163            "expected Unacceptable, got {err:?}"
12164        );
12165        assert!(
12166            elapsed * 4 < SSA_SUCCESSOR_SERVICE_WAIT,
12167            "refusing an Exit that returned nothing took {elapsed:?}, which is within reach of the \
12168             {SSA_SUCCESSOR_SERVICE_WAIT:?} near-miss wait — it must not have entered it at all"
12169        );
12170
12171        bob_sender.close_channel();
12172        bob_handle.await??;
12173        Ok(())
12174    }
12175
12176    /// A request that arrives just ahead of the packets that earned it is waited for, not refused.
12177    ///
12178    /// A conforming Exit asks the instant its reconstructor crosses the threshold, and that request
12179    /// travels the same mixed path as the returned packets which unlocked the shares — so it can
12180    /// overtake the last few of them. Refusing it is not a mild outcome: `RequestSsa` is emitted once
12181    /// per index and never retried, so the Exit sits in `AwaitingCommitment` until
12182    /// `max_ssa_delivery_time` and then closes the Session as `CommitmentTimeout`. The gate must
12183    /// therefore absorb the reordering rather than treat it as under-service.
12184    #[test_log::test(tokio::test)]
12185    async fn a_near_miss_successor_waits_for_the_packets_still_in_flight() -> anyhow::Result<()> {
12186        let params = wide_pix_params();
12187        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12188            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12189
12190        mgr.handle_ssa_request(
12191            pseudonym,
12192            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12193        )
12194        .await
12195        .context("the opening batch must be accepted")?;
12196        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12197
12198        for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12199            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12200        }
12201
12202        // One packet short: the request is in flight ahead of the last one that earned it.
12203        let required = required_returned_packets(&params, 1);
12204        assert!(required > 1, "the fixture needs room to be one short");
12205        credit_returned_packets(&mgr, &pseudonym, required - 1);
12206
12207        // The straggler lands while the request is already being evaluated.
12208        let mgr_clone = mgr.clone();
12209        let late = tokio::spawn(async move {
12210            tokio::time::sleep(SSA_SUCCESSOR_SERVICE_POLL).await;
12211            credit_returned_packets(&mgr_clone, &pseudonym, 1);
12212        });
12213
12214        mgr.handle_ssa_request(
12215            pseudonym,
12216            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12217        )
12218        .await
12219        .context("a request one packet ahead of its own service must be waited for, not refused")?;
12220        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12221        late.await?;
12222
12223        bob_sender.close_channel();
12224        bob_handle.await??;
12225        Ok(())
12226    }
12227
12228    /// Service rendered before the first commitment does not pay for the first successor.
12229    ///
12230    /// Until a cycle is committed the generator holds no polynomials, so the SURBs going out carry no
12231    /// shares — and the Exit may legitimately be served up to `max_predeposit_packets` of them before
12232    /// any deposit exists. Crediting that prefix would let an Exit bank unpaid service against the
12233    /// first cycle it *is* paid for, which is the one window in a Session's life where the gate has no
12234    /// prior cycle to measure against.
12235    #[test_log::test(tokio::test)]
12236    async fn service_returned_before_the_first_commitment_is_not_credited() -> anyhow::Result<()> {
12237        let params = wide_pix_params();
12238        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12239            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12240
12241        // Generously served *before* anything is committed, and none of it may count.
12242        let required = required_returned_packets(&params, 1);
12243        credit_returned_packets(&mgr, &pseudonym, required * 10);
12244
12245        mgr.handle_ssa_request(
12246            pseudonym,
12247            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12248        )
12249        .await
12250        .context("the opening batch must be accepted")?;
12251        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12252
12253        for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12254            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12255        }
12256
12257        let err = mgr
12258            .handle_ssa_request(
12259                pseudonym,
12260                SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12261            )
12262            .await
12263            .expect_err("pre-commitment service must not pay for the first successor");
12264        assert!(
12265            matches!(
12266                err,
12267                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12268            ),
12269            "expected Unacceptable, got {err:?}"
12270        );
12271        assert_eq!(0, drain_pix_events(&mut pix_events).await);
12272
12273        bob_sender.close_channel();
12274        bob_handle.await??;
12275        Ok(())
12276    }
12277
12278    /// The boundary is discounted by exactly the loss the surplus insures against.
12279    ///
12280    /// The Exit unlocks a share when the *first relayer* acknowledges its returned packet, which is
12281    /// upstream of the Entry — so everything lost after that point is progress the Exit legitimately
12282    /// has and this node cannot observe. Demanding the undiscounted emission boundary would refuse
12283    /// conforming Exits on any lossy path; an Exit losing more than the surplus covers could not have
12284    /// reconstructed the cycle at all, so the surplus ratio is the honest allowance.
12285    ///
12286    /// Pinned at the deployed dimensions because the resulting fraction is not something a reader can
12287    /// check by inspection, and it is what decides when real money moves.
12288    #[test]
12289    fn the_successor_boundary_is_discounted_by_the_surplus_it_insures() -> anyhow::Result<()> {
12290        use crate::{DEFAULT_PIX_POLYS_PER_SSA, DEFAULT_PIX_SHARES_PER_POLY, DEFAULT_PIX_SURPLUS_SHARES};
12291
12292        let params = PixParams::try_new(
12293            DEFAULT_PIX_POLYS_PER_SSA,
12294            DEFAULT_PIX_SHARES_PER_POLY,
12295            DEFAULT_PIX_SURPLUS_SHARES,
12296            LOCAL_PIX_SUITE,
12297        )?;
12298        let cycle = params.polys_per_ssa() as u64 * params.emitted_shares_per_poly() as u64;
12299        assert_eq!(655_360, cycle);
12300
12301        let undiscounted = hopr_protocol_pix::min_emission_for_early_recovery(
12302            &params,
12303            hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD,
12304        );
12305        assert_eq!(569_140, undiscounted);
12306
12307        let required = required_returned_packets(&params, 1);
12308        assert_eq!(455_312, required);
12309        assert_eq!(
12310            undiscounted * DEFAULT_PIX_SHARES_PER_POLY as u64
12311                / (DEFAULT_PIX_SHARES_PER_POLY + DEFAULT_PIX_SURPLUS_SHARES) as u64,
12312            required,
12313            "the discount must be the surplus ratio and nothing else"
12314        );
12315
12316        let fraction = required as f64 / cycle as f64;
12317        assert!(
12318            (0.694..0.696).contains(&fraction),
12319            "the deployed successor boundary must sit at ~69.5% of a cycle's returned packets, got {fraction}"
12320        );
12321
12322        // Each further cycle of a batch is demanded in full, at the same discount.
12323        assert_eq!(
12324            required
12325                + cycle * DEFAULT_PIX_SHARES_PER_POLY as u64
12326                    / (DEFAULT_PIX_SHARES_PER_POLY + DEFAULT_PIX_SURPLUS_SHARES) as u64,
12327            required_returned_packets(&params, 2),
12328            "a batch of two must demand the first cycle's whole discounted worth plus the second's boundary"
12329        );
12330
12331        Ok(())
12332    }
12333
12334    /// A real Entry Session must advance the counter the successor gate reads.
12335    ///
12336    /// The gate's worst failure is not being too strict, it is being wired to a counter nothing
12337    /// feeds: that closes it permanently, and every PIX Session then dies on its second cycle with
12338    /// the Exit blaming its own commitment deadline. The other tests here drive
12339    /// `returned_packets` by hand, so none of them would notice.
12340    ///
12341    /// Deliberately built with `surb_management: None`. That is the branch which passed `session_rx`
12342    /// through untouched and left `surb_estimator` at its default — the one place where reusing the
12343    /// balancer's estimate instead of a counter of our own would have produced exactly that silent
12344    /// permanent closure.
12345    #[test_log::test(tokio::test)]
12346    async fn returned_packets_are_counted_on_the_entry_receive_path() -> anyhow::Result<()> {
12347        use hopr_protocol_start::StartProtocolDiscriminants;
12348
12349        let alice_pseudonym = HoprPseudonym::random();
12350        let bob_peer: Address = (&ChainKeypair::random()).into();
12351
12352        let alice_mgr = SessionManager::new(Default::default());
12353        let bob_mgr = SessionManager::new(Default::default());
12354
12355        let mut alice_transport = MockMsgSender::new();
12356        let mut bob_transport = MockMsgSender::new();
12357
12358        // Alice → Bob, and Bob → Alice, with no filtering: the handshake is not what is under test.
12359        let bob_mgr_clone = bob_mgr.clone();
12360        alice_transport.expect_send_message().returning(move |_, data| {
12361            let bob_mgr_clone = bob_mgr_clone.clone();
12362            Box::pin(async move {
12363                let _ = bob_mgr_clone.dispatch_message(
12364                    alice_pseudonym,
12365                    ApplicationDataIn {
12366                        data: data.data,
12367                        packet_info: Default::default(),
12368                    },
12369                );
12370                Ok(())
12371            })
12372        });
12373        let alice_mgr_clone = alice_mgr.clone();
12374        bob_transport
12375            .expect_send_message()
12376            .withf(|_, data| msg_type(data, StartProtocolDiscriminants::SessionEstablished))
12377            .returning(move |_, data| {
12378                let alice_mgr_clone = alice_mgr_clone.clone();
12379                Box::pin(async move {
12380                    alice_mgr_clone.dispatch_message(
12381                        alice_pseudonym,
12382                        ApplicationDataIn {
12383                            data: data.data,
12384                            packet_info: Default::default(),
12385                        },
12386                    )?;
12387                    Ok(())
12388                })
12389            });
12390
12391        let (alice_sender, alice_handle) = mock_packet_planning(alice_transport);
12392        let (new_session_tx_alice, _alice_rx) = futures::channel::mpsc::channel(1024);
12393        alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?;
12394
12395        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
12396        let (new_session_tx_bob, new_session_rx_bob) = futures::channel::mpsc::channel(1024);
12397        bob_mgr.start(bob_sender.clone(), new_session_tx_bob, None, None)?;
12398        let _bob_notifications = tokio::spawn(async move {
12399            pin_mut!(new_session_rx_bob);
12400            while let Some(_session) = new_session_rx_bob.next().await {}
12401        });
12402
12403        let alice_session = alice_mgr
12404            .new_session(
12405                bob_peer,
12406                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
12407                SessionClientConfig {
12408                    pseudonym: alice_pseudonym.into(),
12409                    capabilities: None.into(),
12410                    surb_management: None,
12411                    ..Default::default()
12412                },
12413            )
12414            .await
12415            .context("the session must establish")?;
12416
12417        let counter = alice_mgr
12418            .sessions
12419            .get(&alice_pseudonym)
12420            .expect("alice must hold the session slot")
12421            .returned_packets;
12422        assert_eq!(
12423            0,
12424            counter.load(std::sync::atomic::Ordering::Relaxed),
12425            "nothing has come back yet"
12426        );
12427
12428        // Three packets arriving the way the Exit's return traffic does.
12429        for i in 0..3u8 {
12430            alice_mgr.dispatch_message(
12431                alice_pseudonym,
12432                ApplicationDataIn {
12433                    data: ApplicationData::new(SESSION_APPLICATION_TAG, &[i])?,
12434                    packet_info: Default::default(),
12435                },
12436            )?;
12437        }
12438
12439        // The count is taken as the Session drains its receiver, and the receiver is only polled by a
12440        // reader — which is exactly how it behaves in production, where the client is reading.
12441        let mut alice_session = alice_session;
12442        let mut buffer = [0u8; 16];
12443        for _ in 0..50 {
12444            if counter.load(std::sync::atomic::Ordering::Relaxed) >= 3 {
12445                break;
12446            }
12447            let _ = tokio::time::timeout(
12448                Duration::from_millis(20),
12449                futures::AsyncReadExt::read(&mut alice_session, &mut buffer),
12450            )
12451            .await;
12452        }
12453        assert_eq!(
12454            3,
12455            counter.load(std::sync::atomic::Ordering::Relaxed),
12456            "every Exit → Entry packet must be counted, or the successor gate can never open"
12457        );
12458
12459        drop(alice_session);
12460        alice_sender.close_channel();
12461        bob_sender.close_channel();
12462        let _ = alice_handle.await?;
12463        let _ = bob_handle.await?;
12464        Ok(())
12465    }
12466
12467    /// Re-points the fixture's slot at `Forward` routing, the way a real Entry's slot is built.
12468    ///
12469    /// [`entry_with_pix_session`] establishes through `handle_incoming_session_initiation` because
12470    /// that is the path which installs the PIX state the successor gate needs, and it stores the
12471    /// Exit's `Return` reply routing. The gate is an Entry-side rule, and the counter it reads is
12472    /// only credited on Sessions this node *initiated* — Start-protocol traffic on an incoming
12473    /// Session comes from the Entry over the forward path and must not be paid for. So the routing is
12474    /// what decides whether the keep-alives below are read as service arriving or as service being
12475    /// asked for, and the fixture has to be put on the side the rule is about.
12476    ///
12477    /// Re-inserting rather than mutating: the slot lives in a `moka` cache by value, and its routing
12478    /// is a plain field rather than a cell, because nothing in production ever changes it.
12479    fn make_session_outgoing<S>(mgr: &SessionManager<S>, pseudonym: &HoprPseudonym, destination: Address)
12480    where
12481        S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Clone + Send + Sync + Unpin + 'static,
12482        S::Error: std::error::Error + Send + Sync + 'static,
12483    {
12484        let mut slot = mgr.sessions.get(pseudonym).expect("session must exist");
12485        slot.routing_opts = DestinationRouting::Forward {
12486            destination: Box::new(destination.into()),
12487            pseudonym: Some(*pseudonym),
12488            forward_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN),
12489            return_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN).into(),
12490        };
12491        mgr.sessions.insert(*pseudonym, slot);
12492    }
12493
12494    /// A cycle served only by the Exit's keep-alives must still buy its successor.
12495    ///
12496    /// This is the property that lets the Exit finish a funded cycle whose application has fallen
12497    /// silent: it sends its own Exit → Entry keep-alives, each one consuming a return SURB and
12498    /// therefore unlocking one share, and the cycle completes on those alone. If they earned nothing
12499    /// at this gate the Session would die at exactly the moment it succeeded — the Exit's `RequestSsa`
12500    /// for the successor is emitted once and never retried, so a refusal here leaves it in
12501    /// `AwaitingCommitment` until `max_ssa_delivery_time` and then closes the Session as
12502    /// `CommitmentTimeout`, naming a timer rather than the rule that fired.
12503    ///
12504    /// Driven through [`dispatch_message`](SessionManager::dispatch_message) with real encoded
12505    /// keep-alives rather than by touching the counter, because the credit lives on that path and
12506    /// nowhere else: the message never reaches `session_rx`, so the receive-path inspector that
12507    /// [`returned_packets_are_counted_on_the_entry_receive_path`] pins cannot cover for it.
12508    #[test_log::test(tokio::test)]
12509    async fn keep_alives_alone_can_carry_a_cycle_to_its_successor() -> anyhow::Result<()> {
12510        let params = wide_pix_params();
12511        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12512            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12513
12514        mgr.handle_ssa_request(
12515            pseudonym,
12516            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12517        )
12518        .await
12519        .context("the opening batch must be accepted")?;
12520        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12521
12522        // Emission reaches the boundary, so service is the only thing left for the gate to weigh.
12523        for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12524            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12525        }
12526
12527        make_session_outgoing(&mgr, &pseudonym, (&ChainKeypair::random()).into());
12528
12529        let counter = mgr
12530            .sessions
12531            .get(&pseudonym)
12532            .expect("session must exist")
12533            .returned_packets;
12534        assert_eq!(
12535            0,
12536            counter.load(std::sync::atomic::Ordering::Relaxed),
12537            "the fixture must start with nothing served"
12538        );
12539
12540        // Exactly what the gate demands, and every packet of it a keep-alive.
12541        let required = required_returned_packets(&params, 1);
12542        for level in 0..required {
12543            let keep_alive: ApplicationData = HoprStartProtocol::KeepAlive(KeepAliveMessage {
12544                session_id: pseudonym,
12545                flags: KeepAliveFlag::BalancerState.into(),
12546                additional_data: level,
12547            })
12548            .try_into()?;
12549            mgr.dispatch_message(
12550                pseudonym,
12551                ApplicationDataIn {
12552                    data: keep_alive,
12553                    packet_info: Default::default(),
12554                },
12555            )?;
12556        }
12557        assert_eq!(
12558            required,
12559            counter.load(std::sync::atomic::Ordering::Relaxed),
12560            "every keep-alive consumed a return SURB and must be credited as service"
12561        );
12562
12563        mgr.handle_ssa_request(
12564            pseudonym,
12565            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12566        )
12567        .await
12568        .context("a cycle served entirely by keep-alives must still buy its successor")?;
12569        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12570
12571        bob_sender.close_channel();
12572        bob_handle.await??;
12573        Ok(())
12574    }
12575
12576    /// The Entry's half of the successor gate: a batch asked for before emission has reached the last
12577    /// cycle of the batch already committed must be refused, committing nothing and depositing
12578    /// nothing — and the Session must survive it.
12579    ///
12580    /// The Exit-side gate in `SessionPixSupervisor` means a correct peer never lands here. This is
12581    /// what makes a peer that *does* — an unpatched or hostile Exit answering every cycle's
12582    /// early-recovery signal with a fresh batch — cost the Entry nothing rather than one on-chain
12583    /// deposit per surplus request.
12584    ///
12585    /// Ordering across a batch is pinned where it lives, on the generator, by
12586    /// `emission_never_crosses_a_cycle_boundary_early` and
12587    /// `emission_progress_lags_the_commitment_index_across_a_batch`. What this test drives is the
12588    /// wiring and all three points of the gate: nothing emitted at all, emission having merely
12589    /// *reached* the last committed cycle, and emission far enough into it.
12590    ///
12591    /// The middle point is the regression. Admission used to be "emission has reached the last
12592    /// committed index", which becomes true on that cycle's very first share — so a successor batch
12593    /// was admitted ~0 % into the batch rather than the ~85 % at which a conforming Exit asks. See
12594    /// [`MIN_SUCCESSOR_EMISSION_FRACTION`].
12595    #[test_log::test(tokio::test)]
12596    async fn entry_refuses_a_batch_asked_for_before_emission_reaches_the_current_one() -> anyhow::Result<()> {
12597        use std::collections::BTreeMap;
12598
12599        use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
12600        use hopr_protocol_start::StartInitiation;
12601
12602        const BATCH: u32 = 3;
12603
12604        let generator = Arc::new(SsaShareGenerator::new(SsaGeneratorConfig {
12605            polynomials_per_ssa: 2,
12606            threshold: 2,
12607            surplus_shares: 1,
12608        }));
12609        let (pix_toolbox, pix_events) = PixToolbox::new(
12610            generator.clone(),
12611            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
12612        );
12613        // This manager plays both halves, so its own Exit side opens a cycle and blocks on the
12614        // deposit pool. Answered on the way through; the deposits this test counts pass on unchanged.
12615        let mut pix_events = crate::testing::answering_deposit_pool(pix_events, |_| Vec::new());
12616
12617        let mgr = SessionManager::new(SessionManagerConfig {
12618            max_ssas_per_ssa_request: BATCH as usize,
12619            pix_config: IncomingSessionPixConfig {
12620                quota_range: 0..=1024 * 1024 * 1024,
12621                ..Default::default()
12622            },
12623            ..Default::default()
12624        });
12625
12626        let mut bob_transport = MockMsgSender::new();
12627        bob_transport
12628            .expect_send_message()
12629            .returning(|_, _| Box::pin(async { Ok(()) }));
12630
12631        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
12632        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
12633        let _notifications = tokio::spawn(async move {
12634            pin_mut!(new_session_rx);
12635            while let Some(_session) = new_session_rx.next().await {}
12636        });
12637        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
12638
12639        let pseudonym = HoprPseudonym::random();
12640        mgr.handle_incoming_session_initiation(
12641            pseudonym,
12642            StartInitiation {
12643                challenge: MIN_CHALLENGE,
12644                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
12645                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
12646                additional_data: small_pix_additional_data(),
12647            },
12648        )
12649        .await?;
12650
12651        let identity = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
12652            .expect("identity element must be valid");
12653        let batch = |from: u32| {
12654            (from..from + BATCH)
12655                .map(|i| (SsaIndex::new(i).expect("non-zero"), identity))
12656                .collect::<BTreeMap<_, _>>()
12657        };
12658
12659        // The opening batch is accepted: nothing is committed yet, so there is nothing to be early for.
12660        mgr.handle_ssa_request(
12661            pseudonym,
12662            SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(1), []),
12663        )
12664        .await
12665        .context("the opening batch must be accepted")?;
12666        assert_eq!(
12667            drain_pix_events(&mut pix_events).await,
12668            BATCH as usize,
12669            "the opening batch must yield one deposit address per SSA"
12670        );
12671
12672        // Fully served for the batch, so emission ordering is the only thing under test here. The
12673        // returned-data half of the gate has its own tests.
12674        credit_returned_packets(&mgr, &pseudonym, required_returned_packets(&small_pix_params(), BATCH));
12675
12676        // Asking again before a single share has gone out is a whole batch early.
12677        let err = mgr
12678            .handle_ssa_request(
12679                pseudonym,
12680                SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(BATCH + 1), []),
12681            )
12682            .await
12683            .expect_err("a batch asked for before any share was emitted must be refused");
12684        assert!(
12685            matches!(
12686                err,
12687                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12688            ),
12689            "expected Unacceptable, got {err:?}"
12690        );
12691        assert_eq!(
12692            drain_pix_events(&mut pix_events).await,
12693            0,
12694            "a refused batch must not emit a single ReadyToDeposit"
12695        );
12696        assert_eq!(
12697            mgr.active_sessions(),
12698            vec![pseudonym],
12699            "refusing a request must not close the Session"
12700        );
12701
12702        // Emit until the last cycle of the batch has taken the front — but no further.
12703        let mut sent = 0u32;
12704        let mut emit_until = |pred: &dyn Fn(&hopr_protocol_pix::EmissionProgress) -> bool| -> anyhow::Result<()> {
12705            for _ in 0..1024 {
12706                if generator.emission_progress(&pseudonym).as_ref().is_some_and(pred) {
12707                    return Ok(());
12708                }
12709                sent += 1;
12710                generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12711            }
12712            anyhow::bail!("emission did not reach the expected point")
12713        };
12714        emit_until(&|p| p.is_serving_last_committed())?;
12715
12716        // The boundary the gate actually uses, recomputed from the same negotiated params.
12717        let min_emitted = hopr_protocol_pix::min_emission_for_early_recovery(
12718            &small_pix_params(),
12719            SsaReconstructorConfig::default().early_recovery_threshold,
12720        );
12721
12722        // Reaching the last cycle is *not* enough, and this is the H2 regression. The index-only gate
12723        // this replaced admitted a successor batch right here — on the last cycle's first share, ~0 %
12724        // of the way through the batch — which is nearly a whole cycle of deposits the Exit has not
12725        // earned, on the one gate whose purpose is to prevent exactly that.
12726        let progress = generator.emission_progress(&pseudonym).expect("committed");
12727        assert!(
12728            progress.front_emitted < min_emitted,
12729            "the test must stand at the start of the last cycle, got {} of {min_emitted}",
12730            progress.front_emitted
12731        );
12732        let err = mgr
12733            .handle_ssa_request(
12734                pseudonym,
12735                SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(BATCH + 1), []),
12736            )
12737            .await
12738            .expect_err("reaching the last cycle must not by itself admit the next batch");
12739        assert!(
12740            matches!(
12741                err,
12742                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12743            ),
12744            "expected Unacceptable, got {err:?}"
12745        );
12746        assert_eq!(
12747            drain_pix_events(&mut pix_events).await,
12748            0,
12749            "a batch refused for being early must not emit a single ReadyToDeposit"
12750        );
12751
12752        // Far enough into that last cycle, the same batch is admitted.
12753        emit_until(&|p| p.front_emitted >= min_emitted)?;
12754        mgr.handle_ssa_request(
12755            pseudonym,
12756            SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(BATCH + 1), []),
12757        )
12758        .await
12759        .context("a batch asked for on time must be accepted")?;
12760
12761        bob_sender.close_channel();
12762        bob_handle.await??;
12763        Ok(())
12764    }
12765
12766    /// Concurrent `SsaRequest`s for one pseudonym must admit exactly one batch.
12767    ///
12768    /// Start messages are processed under `for_each_concurrent`, so several requests for the same
12769    /// Session run at once, and each reads the successor gate before any of them has advanced it.
12770    /// Requests are issued at distinct, ascending index ranges because that is the shape the
12771    /// generator's monotonic index cannot refuse — it rejects equal or lower indices, and an Exit
12772    /// numbering its batches upwards never offers one.
12773    ///
12774    /// **What this pins is the invariant, not the mechanism.** At this fixture's dimensions — two
12775    /// polynomials at threshold two — `new_ssa_commitment` returns in microseconds, so the window
12776    /// between reading the gate and advancing it is too narrow to hit: removing
12777    /// [`ssa_request_locks`](SessionManager::ssa_request_locks) leaves the outcome unchanged here.
12778    /// The window is a function of that call's cost, which at the deployed 8192 x 64 is around a
12779    /// second, and a fixture that large is not something to build into a unit test. So this test
12780    /// guards the property and the serialisation is justified by the shape of the code rather than
12781    /// by this failing without it.
12782    ///
12783    /// It does earn its place: it is what would catch the gate being made per-entry, the guard being
12784    /// taken after the gate instead of before it, or the handler gaining an await between the two.
12785    #[test_log::test(tokio::test(flavor = "multi_thread", worker_threads = 4))]
12786    async fn concurrent_ssa_requests_for_one_pseudonym_admit_one_batch() -> anyhow::Result<()> {
12787        use std::collections::BTreeMap;
12788
12789        use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
12790        use hopr_protocol_start::StartInitiation;
12791
12792        const BATCH: u32 = 2;
12793        const RACERS: u32 = 4;
12794
12795        let generator = Arc::new(SsaShareGenerator::new(SsaGeneratorConfig {
12796            polynomials_per_ssa: 2,
12797            threshold: 2,
12798            surplus_shares: 1,
12799        }));
12800        let (pix_toolbox, pix_events) = PixToolbox::new(
12801            generator.clone(),
12802            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
12803        );
12804        // This manager plays both halves, so its own Exit side opens a cycle and blocks on the
12805        // deposit pool. Answered on the way through; the deposits this test counts pass on unchanged.
12806        let mut pix_events = crate::testing::answering_deposit_pool(pix_events, |_| Vec::new());
12807
12808        let mgr = SessionManager::new(SessionManagerConfig {
12809            max_ssas_per_ssa_request: BATCH as usize,
12810            pix_config: IncomingSessionPixConfig {
12811                quota_range: 0..=1024 * 1024 * 1024,
12812                ..Default::default()
12813            },
12814            ..Default::default()
12815        });
12816
12817        let mut bob_transport = MockMsgSender::new();
12818        bob_transport
12819            .expect_send_message()
12820            .returning(|_, _| Box::pin(async { Ok(()) }));
12821
12822        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
12823        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
12824        let _notifications = tokio::spawn(async move {
12825            pin_mut!(new_session_rx);
12826            while let Some(_session) = new_session_rx.next().await {}
12827        });
12828        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
12829
12830        let pseudonym = HoprPseudonym::random();
12831        mgr.handle_incoming_session_initiation(
12832            pseudonym,
12833            StartInitiation {
12834                challenge: MIN_CHALLENGE,
12835                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
12836                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
12837                additional_data: small_pix_additional_data(),
12838            },
12839        )
12840        .await?;
12841
12842        let identity = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
12843            .expect("identity element must be valid");
12844        let batch = |from: u32| {
12845            (from..from + BATCH)
12846                .map(|i| (SsaIndex::new(i).expect("non-zero"), identity))
12847                .collect::<BTreeMap<_, _>>()
12848        };
12849
12850        // The opening batch, then emission far enough into its last cycle that exactly one successor
12851        // batch is legitimately admissible.
12852        mgr.handle_ssa_request(
12853            pseudonym,
12854            SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(1), []),
12855        )
12856        .await
12857        .context("the opening batch must be accepted")?;
12858        assert_eq!(BATCH as usize, drain_pix_events(&mut pix_events).await);
12859
12860        // Served for the batch, so that serialisation is the only thing deciding the outcome below.
12861        credit_returned_packets(&mgr, &pseudonym, required_returned_packets(&small_pix_params(), BATCH));
12862
12863        let min_emitted = hopr_protocol_pix::min_emission_for_early_recovery(
12864            &small_pix_params(),
12865            SsaReconstructorConfig::default().early_recovery_threshold,
12866        );
12867        let mut sent = 0u32;
12868        for _ in 0..1024 {
12869            if generator
12870                .emission_progress(&pseudonym)
12871                .is_some_and(|p| p.is_serving_last_committed() && p.front_emitted >= min_emitted)
12872            {
12873                break;
12874            }
12875            sent += 1;
12876            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12877        }
12878
12879        // Real tasks on a multi-threaded runtime, not `join_all`: `join_all` polls in order, so the
12880        // first future runs its gate check and its whole commitment phase before the second is polled
12881        // at all, and the requests never actually overlap. That shape passes with or without the
12882        // serialisation and so proves nothing about it.
12883        let barrier = Arc::new(tokio::sync::Barrier::new(RACERS as usize));
12884        let outcomes = futures::future::join_all((0..RACERS).map(|r| {
12885            let mgr = mgr.clone();
12886            let barrier = barrier.clone();
12887            let commitments = batch(BATCH + 1 + r * BATCH);
12888            tokio::spawn(async move {
12889                barrier.wait().await;
12890                mgr.handle_ssa_request(
12891                    pseudonym,
12892                    SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), commitments, []),
12893                )
12894                .await
12895            })
12896        }))
12897        .await
12898        .into_iter()
12899        .map(|joined| joined.expect("racer task must not panic"))
12900        .collect::<Vec<_>>();
12901
12902        let admitted = outcomes.iter().filter(|o| o.is_ok()).count();
12903        assert_eq!(
12904            1, admitted,
12905            "exactly one of {RACERS} concurrent requests may be admitted, got {admitted}: {outcomes:?}"
12906        );
12907        assert_eq!(
12908            BATCH as usize,
12909            drain_pix_events(&mut pix_events).await,
12910            "only the admitted batch may produce deposit instructions"
12911        );
12912
12913        bob_sender.close_channel();
12914        bob_handle.await??;
12915        Ok(())
12916    }
12917
12918    /// An `SsaRequest` asking for more than [`SessionManagerConfig::max_ssas_per_ssa_request`] SSA
12919    /// commitments must be rejected outright, before any commitment is generated or any
12920    /// `ReadyToDeposit` is emitted — and a batch *at* the configured cap must be accepted, so that
12921    /// raising the knob is what actually admits a larger batch.
12922    ///
12923    /// Each accepted entry costs a full client commitment plus its own on-chain deposit, so without
12924    /// this cap one inbound packet could amplify into up to `MAX_SSAS_PER_REQUEST` (27) deposits.
12925    #[test_log::test(tokio::test)]
12926    async fn entry_rejects_ssa_request_exceeding_configured_ssa_cap() -> anyhow::Result<()> {
12927        use std::collections::BTreeMap;
12928
12929        use hopr_crypto_packet::prelude::HoprPixGroupElement;
12930        use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
12931        use hopr_protocol_start::StartInitiation;
12932
12933        /// Outcome of offering a batch: what `handle_ssa_request` returned, how many `SessionError`
12934        /// messages the Entry sent back, and whether it kept the Session.
12935        struct Outcome {
12936            result: errors::Result<()>,
12937            session_errors: usize,
12938            session_alive: bool,
12939        }
12940
12941        // `batch` entries offered against an Entry configured to accept at most `cap`.
12942        async fn offer_batch(cap: usize, batch: u32) -> anyhow::Result<Outcome> {
12943            // The PIX event stream must stay alive: an accepted batch emits one `ReadyToDeposit` per
12944            // entry, and a dropped receiver would fail the send and mask the acceptance as an error.
12945            let (pix_toolbox, _pix_events) = PixToolbox::new(
12946                SsaShareGenerator::new(SsaGeneratorConfig {
12947                    polynomials_per_ssa: 2,
12948                    threshold: 2,
12949                    surplus_shares: 1,
12950                })
12951                .into(),
12952                SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
12953            );
12954
12955            let mgr = SessionManager::new(SessionManagerConfig {
12956                pix_config: IncomingSessionPixConfig {
12957                    quota_range: 0..=1024 * 1024 * 1024,
12958                    ..Default::default()
12959                },
12960                max_ssas_per_ssa_request: cap,
12961                ..Default::default()
12962            });
12963
12964            // Count the SessionError replies: a refusal has to be *told* to the Exit, which cannot
12965            // otherwise observe it and has no path back to a new request.
12966            let session_errors = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12967            let session_errors_tx = session_errors.clone();
12968            let mut bob_transport = MockMsgSender::new();
12969            bob_transport
12970                .expect_send_message()
12971                .times(1..)
12972                .returning(move |_, data| {
12973                    if crate::testing::msg_type(&data, StartProtocolDiscriminants::SessionError) {
12974                        session_errors_tx.fetch_add(1, Ordering::Relaxed);
12975                    }
12976                    Box::pin(async { Ok(()) })
12977                });
12978
12979            let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
12980            let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
12981            let _notifications = tokio::spawn(async move {
12982                pin_mut!(new_session_rx);
12983                while let Some(_session) = new_session_rx.next().await {}
12984            });
12985            mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
12986
12987            let alice_pseudonym = HoprPseudonym::random();
12988
12989            mgr.handle_incoming_session_initiation(
12990                alice_pseudonym,
12991                StartInitiation {
12992                    challenge: MIN_CHALLENGE,
12993                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
12994                    capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
12995                    additional_data: small_pix_additional_data(),
12996                },
12997            )
12998            .await?;
12999
13000            let session_id = alice_pseudonym;
13001            let identity = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
13002                .expect("identity element must be valid");
13003
13004            // Indices start above whatever the Exit-side establishment already allocated, so the
13005            // accepted case is not rejected for non-monotonicity instead of for its size.
13006            let commitments: BTreeMap<_, _> = (100..100 + batch)
13007                .map(|i| (SsaIndex::new(i).expect("non-zero"), identity))
13008                .collect();
13009
13010            let result = mgr
13011                .handle_ssa_request(
13012                    alice_pseudonym,
13013                    SsaServerCommitmentMessage::new(
13014                        session_id,
13015                        PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE)?,
13016                        commitments,
13017                        [],
13018                    ),
13019                )
13020                .await;
13021
13022            let session_alive = mgr.active_sessions().contains(&session_id);
13023
13024            bob_sender.close_channel();
13025            bob_handle.await??;
13026
13027            Ok(Outcome {
13028                result,
13029                session_errors: session_errors.load(Ordering::Relaxed),
13030                session_alive,
13031            })
13032        }
13033
13034        // One over the default cap is rejected...
13035        let over_cap = offer_batch(
13036            DEFAULT_MAX_SSAS_PER_SSA_REQUEST,
13037            DEFAULT_MAX_SSAS_PER_SSA_REQUEST as u32 + 1,
13038        )
13039        .await?;
13040        assert!(
13041            matches!(
13042                over_cap.result,
13043                Err(TransportSessionError::Manager(SessionManagerError::Unacceptable(_)))
13044            ),
13045            "an over-cap SsaRequest must be rejected, got {:?}",
13046            over_cap.result
13047        );
13048        // ...and the refusal must be reported rather than left for the Exit to infer from its own
13049        // deposit timeout minutes later, and must not leave a Session behind that can never make PIX
13050        // progress.
13051        assert_eq!(
13052            1, over_cap.session_errors,
13053            "a refused SsaRequest must send exactly one SessionError back to the Exit"
13054        );
13055        assert!(
13056            !over_cap.session_alive,
13057            "a refused SsaRequest must tear down the Entry's half of the Session"
13058        );
13059
13060        // ...and the very same batch is accepted once the cap is raised to admit it, proving the
13061        // rejection is the configured cap talking and not some other validation.
13062        let raised = DEFAULT_MAX_SSAS_PER_SSA_REQUEST + 1;
13063        let accepted = offer_batch(raised, raised as u32).await?;
13064        assert!(
13065            accepted.result.is_ok(),
13066            "a batch at the configured cap of {raised} must be accepted, got {:?}",
13067            accepted.result
13068        );
13069        assert_eq!(
13070            0, accepted.session_errors,
13071            "an accepted batch must not send a SessionError"
13072        );
13073        assert!(
13074            accepted.session_alive,
13075            "an accepted batch must leave the Session running"
13076        );
13077
13078        Ok(())
13079    }
13080
13081    /// A retransmission request must be capped on the *cycles* it names, not only on the runs the
13082    /// wire can carry.
13083    ///
13084    /// One 8-byte run names a whole cycle, so a request naming every retained cycle is answered with
13085    /// a full commitment burst for each — thousands of packets out of a single packet in. The
13086    /// generator's per-cycle attempt cap does not bound that: it bounds repeats of one cycle, and
13087    /// each cycle here is being asked for the first time.
13088    #[test_log::test(tokio::test)]
13089    async fn entry_rejects_a_retransmission_request_naming_too_many_cycles() -> anyhow::Result<()> {
13090        use std::collections::BTreeMap;
13091
13092        use hopr_crypto_packet::prelude::HoprPixGroupElement;
13093        use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
13094        use hopr_protocol_start::StartInitiation;
13095
13096        /// Commits `cap` cycles to an Entry capped at `cap`, then asks it to retransmit `cycles` of
13097        /// them, and reports what `handle_ssa_request` made of the second request together with every
13098        /// `SsaCommit` the Entry sent across both — one per cycle at these dimensions, so the
13099        /// commitment alone accounts for `cap` of them.
13100        ///
13101        /// Committing first is what gives the assertions their teeth: an uncapped handler answers
13102        /// every *retained* index it is named before it trips over the first one it does not hold, so
13103        /// the packet count — not the error — is what separates a gate from an accident. The count is
13104        /// read only after the send channel has drained, since the bursts are queued rather than sent
13105        /// inline and a running total would race them.
13106        async fn ask_for_cycles(cap: usize, cycles: u32) -> anyhow::Result<(errors::Result<()>, usize)> {
13107            let (pix_toolbox, _pix_events) = PixToolbox::new(
13108                SsaShareGenerator::new(SsaGeneratorConfig {
13109                    polynomials_per_ssa: 2,
13110                    threshold: 2,
13111                    surplus_shares: 1,
13112                })
13113                .into(),
13114                SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13115            );
13116
13117            let mgr = SessionManager::new(SessionManagerConfig {
13118                pix_config: IncomingSessionPixConfig {
13119                    quota_range: 0..=1024 * 1024 * 1024,
13120                    ..Default::default()
13121                },
13122                max_ssas_per_ssa_request: cap,
13123                ..Default::default()
13124            });
13125
13126            // The amplification is the thing being bounded, so count what actually goes out.
13127            let commits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13128            let commits_tx = commits.clone();
13129            let mut bob_transport = MockMsgSender::new();
13130            bob_transport
13131                .expect_send_message()
13132                .times(1..)
13133                .returning(move |_, data| {
13134                    if crate::testing::msg_type(&data, StartProtocolDiscriminants::SsaCommit) {
13135                        commits_tx.fetch_add(1, Ordering::Relaxed);
13136                    }
13137                    Box::pin(async { Ok(()) })
13138                });
13139
13140            let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13141            let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13142            let _notifications = tokio::spawn(async move {
13143                pin_mut!(new_session_rx);
13144                while let Some(_session) = new_session_rx.next().await {}
13145            });
13146            mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
13147
13148            let alice_pseudonym = HoprPseudonym::random();
13149            mgr.handle_incoming_session_initiation(
13150                alice_pseudonym,
13151                StartInitiation {
13152                    challenge: MIN_CHALLENGE,
13153                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13154                    capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13155                    additional_data: small_pix_additional_data(),
13156                },
13157            )
13158            .await?;
13159
13160            let params = PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE)?;
13161            let identity = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
13162                .expect("identity element must be valid");
13163
13164            // Commit a full batch first, so there is real material for the retransmission to name.
13165            let commitments: BTreeMap<_, _> = (100..100 + cap as u32)
13166                .map(|i| (SsaIndex::new(i).expect("non-zero"), identity))
13167                .collect();
13168            mgr.handle_ssa_request(
13169                alice_pseudonym,
13170                SsaServerCommitmentMessage::new(alice_pseudonym, params, commitments, []),
13171            )
13172            .await?;
13173
13174            // One run apiece, each covering its whole cycle: the cheapest request to send and the
13175            // most expensive to answer.
13176            let missing = (100..100 + cycles).map(|i| (SsaIndex::new(i).expect("non-zero"), vec![(0u16, 1u16)]));
13177
13178            let result = mgr
13179                .handle_ssa_request(
13180                    alice_pseudonym,
13181                    SsaServerCommitmentMessage::recommit(alice_pseudonym, params, missing),
13182                )
13183                .await;
13184
13185            bob_sender.close_channel();
13186            bob_handle.await??;
13187
13188            Ok((result, commits.load(Ordering::Relaxed)))
13189        }
13190
13191        // One cycle over the cap is refused whole, and — the part that matters — answered with
13192        // nothing at all, rather than with a burst for each retained cycle it managed to name first.
13193        let (over_cap, over_cap_commits) = ask_for_cycles(
13194            DEFAULT_MAX_SSAS_PER_SSA_REQUEST,
13195            DEFAULT_MAX_SSAS_PER_SSA_REQUEST as u32 + 1,
13196        )
13197        .await?;
13198        assert!(
13199            matches!(
13200                over_cap,
13201                Err(TransportSessionError::Manager(SessionManagerError::Unacceptable(_)))
13202            ),
13203            "a retransmission request over the cycle cap must be refused, got {over_cap:?}"
13204        );
13205        assert_eq!(
13206            DEFAULT_MAX_SSAS_PER_SSA_REQUEST, over_cap_commits,
13207            "a refused request must add nothing to the {DEFAULT_MAX_SSAS_PER_SSA_REQUEST} messages the commitment \
13208             itself cost"
13209        );
13210
13211        // ...and the very same cycles are repaired once they fit the cap, so the refusal above is the
13212        // cap talking and not some other validation the request also trips.
13213        let (at_cap, at_cap_commits) = ask_for_cycles(
13214            DEFAULT_MAX_SSAS_PER_SSA_REQUEST,
13215            DEFAULT_MAX_SSAS_PER_SSA_REQUEST as u32,
13216        )
13217        .await?;
13218        assert!(
13219            at_cap.is_ok(),
13220            "a request at the cycle cap must be answered, got {at_cap:?}"
13221        );
13222        assert_eq!(
13223            2 * DEFAULT_MAX_SSAS_PER_SSA_REQUEST,
13224            at_cap_commits,
13225            "every named cycle must be re-sent, one message each on top of the commitment's own"
13226        );
13227
13228        Ok(())
13229    }
13230
13231    /// A batch with one undecodable member must publish nothing at all.
13232    ///
13233    /// `handle_ssa_request` used to generate, send and emit per entry, deciding each member's fate
13234    /// before looking at the next. A batch whose *second* exit commitment failed to decode had
13235    /// therefore already sent the first member's `SsaCommit` burst and emitted its `ReadyToDeposit`
13236    /// — an instruction to put money on chain — for a request that is then rejected as a whole. The
13237    /// Exit has no cycle to spend that deposit against, and the deposit key needs its half.
13238    ///
13239    /// The first member is valid and the second is not, so a per-entry implementation passes the
13240    /// "batch is refused" half of this test while still leaking the first deposit.
13241    #[test_log::test(tokio::test)]
13242    async fn a_batch_with_one_undecodable_commitment_publishes_nothing() -> anyhow::Result<()> {
13243        use std::collections::BTreeMap;
13244
13245        use futures::FutureExt;
13246        use hopr_crypto_packet::prelude::HoprPixGroupElement;
13247        use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
13248        use hopr_protocol_start::StartInitiation;
13249
13250        let (pix_toolbox, pix_events) = PixToolbox::new(
13251            SsaShareGenerator::new(SsaGeneratorConfig {
13252                polynomials_per_ssa: 2,
13253                threshold: 2,
13254                surplus_shares: 1,
13255            })
13256            .into(),
13257            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13258        );
13259
13260        let mgr = SessionManager::new(SessionManagerConfig {
13261            pix_config: IncomingSessionPixConfig {
13262                quota_range: 0..=1024 * 1024 * 1024,
13263                ..Default::default()
13264            },
13265            ..Default::default()
13266        });
13267
13268        // `SsaCommit` is the observable half of "a commitment was published"; `SessionError` is the
13269        // refusal. Both are counted, because the bug shows up as the two happening together.
13270        let commits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13271        let session_errors = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13272        let (commits_tx, errors_tx) = (commits.clone(), session_errors.clone());
13273        let mut bob_transport = MockMsgSender::new();
13274        bob_transport.expect_send_message().returning(move |_, data| {
13275            if crate::testing::msg_type(&data, StartProtocolDiscriminants::SsaCommit) {
13276                commits_tx.fetch_add(1, Ordering::Relaxed);
13277            }
13278            if crate::testing::msg_type(&data, StartProtocolDiscriminants::SessionError) {
13279                errors_tx.fetch_add(1, Ordering::Relaxed);
13280            }
13281            Box::pin(async { Ok(()) })
13282        });
13283
13284        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13285        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13286        let _notifications = tokio::spawn(async move {
13287            pin_mut!(new_session_rx);
13288            while let Some(_session) = new_session_rx.next().await {}
13289        });
13290        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
13291
13292        let alice_pseudonym = HoprPseudonym::random();
13293        mgr.handle_incoming_session_initiation(
13294            alice_pseudonym,
13295            StartInitiation {
13296                challenge: MIN_CHALLENGE,
13297                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13298                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13299                additional_data: small_pix_additional_data(),
13300            },
13301        )
13302        .await?;
13303
13304        let valid = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
13305            .expect("identity element must be valid");
13306        // All-ones is not a compressed point on the curve, so it fails before the subgroup check.
13307        // The length is taken from the type rather than written out: the group representation is
13308        // curve-dependent, and a literal silently becomes a *length* rejection under a different
13309        // `HoprPixSpec` — which is a different code path from the one under test.
13310        // Spelled out because the group repr is a `hybrid_array::Array` with several `AsRef` impls.
13311        let repr_len = AsRef::<[u8]>::as_ref(&PixGroup::<HoprPixSpec>::default().to_bytes()).len();
13312        let garbage =
13313            HoprPixGroupElement::try_from(vec![0xffu8; repr_len].as_slice()).expect("length must be accepted");
13314        assert!(
13315            garbage.try_into_pix_group().is_err(),
13316            "the test fixture must actually be undecodable"
13317        );
13318
13319        let commitments: BTreeMap<_, _> = BTreeMap::from([
13320            (SsaIndex::new(100).expect("non-zero"), valid),
13321            (SsaIndex::new(101).expect("non-zero"), garbage),
13322        ]);
13323
13324        let result = mgr
13325            .handle_ssa_request(
13326                alice_pseudonym,
13327                SsaServerCommitmentMessage::new(
13328                    alice_pseudonym,
13329                    PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE)?,
13330                    commitments,
13331                    [],
13332                ),
13333            )
13334            .await;
13335
13336        let session_alive = mgr.active_sessions().contains(&alice_pseudonym);
13337
13338        // Drain the mock transport before counting: `mock_packet_planning` delivers on a spawned
13339        // task, so a count taken here would race the sends rather than observe them.
13340        bob_sender.close_channel();
13341        bob_handle.await??;
13342
13343        assert!(
13344            matches!(
13345                result,
13346                Err(TransportSessionError::Manager(SessionManagerError::Unacceptable(_)))
13347            ),
13348            "an undecodable member must be refused, got {result:?}"
13349        );
13350        assert_eq!(
13351            0,
13352            commits.load(Ordering::Relaxed),
13353            "no SsaCommit may be sent for a batch that is refused as a whole"
13354        );
13355        assert_eq!(
13356            1,
13357            session_errors.load(Ordering::Relaxed),
13358            "the refusal must be reported to the Exit"
13359        );
13360        assert!(
13361            !session_alive,
13362            "an undecodable commitment is terminal, like every other unacceptable-parameter case"
13363        );
13364
13365        // The point of the test: not one deposit instruction escaped for the valid first member.
13366        pin_mut!(pix_events);
13367        assert!(
13368            pix_events.next().now_or_never().flatten().is_none(),
13369            "no ReadyToDeposit may be emitted for a batch that is refused as a whole"
13370        );
13371
13372        Ok(())
13373    }
13374
13375    /// The Start protocol ingress channel must be sized for the worst-case commitment burst of a
13376    /// single SSA cycle, not for the number of sessions.
13377    ///
13378    /// A dropped `SsaCommit` is unrecoverable — there is no retransmission — so the queue has to
13379    /// absorb the whole commitment set a cycle can deliver, capped by the polynomial ceiling
13380    /// `check_pix_params` enforces.
13381    #[test]
13382    fn start_protocol_channel_is_sized_for_the_worst_case_commitment_burst() {
13383        let cfg = SessionManagerConfig::default();
13384        let capacity = start_protocol_channel_capacity(&cfg);
13385
13386        // Number of commitments implied by the largest quota this node accepts, clamped by the
13387        // number of polynomials it would actually admit.
13388        let commitments = (*cfg.pix_config.quota_range.end() / PIX_QUOTA_BYTES_PER_SHARE).min(MAX_POLYS_PER_SSA as u64);
13389        let min_expected = commitments.div_ceil(MIN_COMMITMENTS_PER_SSA_COMMIT_MSG as u64) as usize;
13390
13391        assert!(
13392            capacity >= min_expected + cfg.maximum_sessions,
13393            "capacity {capacity} must cover the {min_expected}-message commitment burst plus room for {} concurrent \
13394             session setups",
13395            cfg.maximum_sessions
13396        );
13397
13398        // The commitment burst must be the term that was added, not incidental slack: the old
13399        // sizing was `maximum_sessions + 10` and carried no PIX component at all.
13400        assert_eq!(
13401            min_expected,
13402            capacity - cfg.maximum_sessions - START_PROTOCOL_CHANNEL_RESERVE,
13403            "the PIX commitment burst must be an explicit component of the capacity"
13404        );
13405        assert!(
13406            min_expected > 0,
13407            "a non-zero accepted quota must imply commitment messages"
13408        );
13409
13410        // Guards against a units mistake (e.g. counting bytes rather than messages) turning this
13411        // into a multi-gigabyte ring allocation.
13412        assert!(capacity < 1_000_000, "capacity {capacity} is implausibly large");
13413    }
13414
13415    /// `quota_range` is operator-settable and the capacity it feeds is *reserved*, not merely
13416    /// enforced — `crossfire`'s array flavour pre-allocates every slot. So the derivation must be
13417    /// bounded independently of the configured quota.
13418    ///
13419    /// Regression test: an unclamped derivation asked for ~4.2e8 slots here (77 GB), which aborted
13420    /// the whole unit test binary with an allocation failure rather than failing one assertion. The
13421    /// sizing test above never caught it because it only exercises the default config.
13422    #[test]
13423    fn start_protocol_channel_capacity_is_bounded_for_any_quota_range() {
13424        // The commitment term saturates once the quota admits more commitments than there are
13425        // polynomials, so this is the largest value it can ever take.
13426        let saturated = (MAX_POLYS_PER_SSA as u64).div_ceil(MIN_COMMITMENTS_PER_SSA_COMMIT_MSG as u64) as usize;
13427
13428        for quota_end in [10_u64.pow(13), u64::MAX] {
13429            let cfg = SessionManagerConfig {
13430                pix_config: IncomingSessionPixConfig {
13431                    quota_range: 0..=quota_end,
13432                    ..Default::default()
13433                },
13434                ..Default::default()
13435            };
13436            let capacity = start_protocol_channel_capacity(&cfg);
13437
13438            assert_eq!(
13439                capacity,
13440                saturated + cfg.maximum_sessions + START_PROTOCOL_CHANNEL_RESERVE,
13441                "quota_range end {quota_end} must not grow the commitment term past the polynomial ceiling"
13442            );
13443        }
13444
13445        // The session term is reserved for exactly the same reason, and `maximum_managed_sessions`
13446        // validates up to 100 000 — so it needs its own ceiling, not just the commitment one.
13447        let cfg = SessionManagerConfig {
13448            maximum_sessions: 100_000,
13449            ..Default::default()
13450        };
13451        assert_eq!(
13452            start_protocol_channel_capacity(&cfg),
13453            saturated + MAX_CONCURRENT_START_EXCHANGES + START_PROTOCOL_CHANNEL_RESERVE,
13454            "a large session limit must not grow the pre-allocated ring past the handshake ceiling"
13455        );
13456
13457        // The batch factor is reserved too: a batch of N draws N cycles' commitment sets into this
13458        // one channel, and a dropped `SsaCommit` is unrecoverable. It must scale the commitment term
13459        // and nothing else, and must itself be bounded by `MAX_SSA_BATCH_SIZE` even when the config
13460        // was never clamped by `SessionManager::new`.
13461        let batched = |ssas_per_request| {
13462            start_protocol_channel_capacity(&SessionManagerConfig {
13463                pix_config: IncomingSessionPixConfig {
13464                    quota_range: 0..=u64::MAX,
13465                    supervision: SupervisorConfig {
13466                        ssas_per_request,
13467                        ..Default::default()
13468                    },
13469                    ..Default::default()
13470                },
13471                ..Default::default()
13472            })
13473        };
13474        let unbatched = SessionManagerConfig::default();
13475        for ssas_per_request in [1, 2, MAX_SSA_BATCH_SIZE] {
13476            assert_eq!(
13477                batched(ssas_per_request),
13478                saturated * ssas_per_request + unbatched.maximum_sessions + START_PROTOCOL_CHANNEL_RESERVE,
13479                "the commitment term must scale with ssas_per_request = {ssas_per_request}"
13480            );
13481        }
13482        assert_eq!(
13483            batched(MAX_SSA_BATCH_SIZE + 1),
13484            batched(MAX_SSA_BATCH_SIZE),
13485            "an unclamped ssas_per_request must not inflate the pre-allocated ring"
13486        );
13487        assert_eq!(
13488            batched(0),
13489            batched(1),
13490            "a zero ssas_per_request must not collapse the commitment term"
13491        );
13492    }
13493
13494    /// Verifies that once the exit/responder (Bob) has set up the SSA state, delivering coefficient
13495    /// commits for all polynomials causes the PIX event stream to emit `DepositNeeded`.
13496    ///
13497    /// ## Steps
13498    /// 1. Bob's manager is started with a `PixToolbox` configured for `polynomials_per_ssa=2, threshold=2,
13499    ///    surplus_shares=1`. Alice's session initiation is processed normally.
13500    /// 2. The exit has already registered an exit commitment from `handle_incoming_session_initiation`.
13501    /// 3. Coefficient 0 (constant terms across all polynomials) is delivered via `handle_ssa_commit` using identity
13502    ///    group elements as dummy commitments.
13503    /// 4. Coefficient 1 (linear terms) is delivered similarly.
13504    /// 5. After the second coefficient delivery, Bob's PIX event stream emits `DepositNeeded` with the correct `SsaId`
13505    ///    and `quota_per_ssa` matching `pix_params_to_quota(2, 2)`.
13506    /// 6. The event is received within a 2-second timeout.
13507    #[test_log::test(tokio::test)]
13508    async fn exit_receives_ssa_commits_and_emits_deposit_needed_event() -> anyhow::Result<()> {
13509        use std::collections::HashMap;
13510
13511        use hopr_crypto_packet::prelude::{HoprPixCommitmentProof, HoprPixGroupElement};
13512        use hopr_protocol_pix::{
13513            Field, PixGroup, PixScalar, PolynomialIndex, SsaCommitmentProof, SsaGeneratorConfig, SsaReconstructor,
13514            SsaReconstructorConfig, SsaShareGenerator,
13515        };
13516        use hopr_protocol_start::StartInitiation;
13517
13518        let ssa_gen_config = SsaGeneratorConfig {
13519            polynomials_per_ssa: 2,
13520            threshold: 2,
13521            surplus_shares: 1,
13522        };
13523
13524        let (pix_toolbox, pix_events_rx) = PixToolbox::new(
13525            SsaShareGenerator::new(ssa_gen_config).into(),
13526            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13527        );
13528        // This test reads the event stream itself, so the pool has to be played on the way through:
13529        // the Exit blocks its SSA request on the answer, and the request would otherwise be the
13530        // first event read here.
13531        let pix_events_rx =
13532            crate::testing::answering_deposit_pool(pix_events_rx, |id| vec![id.ssa_index().get() as u8; 3]);
13533
13534        let mgr = SessionManager::new(SessionManagerConfig {
13535            pix_config: IncomingSessionPixConfig {
13536                quota_range: 0..=1024 * 1024 * 1024,
13537                ..Default::default()
13538            },
13539            ..Default::default()
13540        });
13541
13542        let mut bob_transport = MockMsgSender::new();
13543        bob_transport
13544            .expect_send_message()
13545            .returning(|_, _| Box::pin(async { Ok(()) }));
13546        // handle_incoming_session_initiation sends SessionEstablished + SsaRequest.
13547        bob_transport
13548            .expect_send_message()
13549            .returning(|_, _| Box::pin(async { Ok(()) }));
13550
13551        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13552        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13553        let _notifications = tokio::spawn(async move {
13554            pin_mut!(new_session_rx);
13555            while let Some(_session) = new_session_rx.next().await {}
13556        });
13557        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox.clone()), None)?;
13558
13559        let alice_pseudonym = HoprPseudonym::random();
13560
13561        mgr.handle_incoming_session_initiation(
13562            alice_pseudonym,
13563            StartInitiation {
13564                challenge: MIN_CHALLENGE,
13565                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13566                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13567                additional_data: small_pix_additional_data(),
13568            },
13569        )
13570        .await?;
13571
13572        // The exit commitment is registered by the supervisor's opening `RequestSsa`, which the
13573        // action driver carries out asynchronously — so wait for it rather than assume it.
13574        let ssa_id = SsaId::new(alice_pseudonym, SsaIndex::MIN);
13575        {
13576            let share_processor = pix_toolbox.share_processor.clone();
13577            tokio::time::timeout(Duration::from_secs(1), async {
13578                while !share_processor.contains_builder(&ssa_id) {
13579                    tokio::time::sleep(Duration::from_millis(10)).await;
13580                }
13581            })
13582            .await
13583            .context("the opening SSA request never registered an exit commitment")?;
13584        }
13585
13586        // Deliver coefficient 0 (constant terms across all polynomials).
13587        // Use the identity/infinity group element as a dummy commitment.
13588        // PixGroup<HoprPixSpec> = k256::ProjectivePoint, which has identity/infinity as all-zero bytes.
13589        let identity_element = {
13590            let g: PixGroup<HoprPixSpec> = Default::default();
13591            HoprPixGroupElement::try_from(g.to_bytes().as_ref()).expect("identity element must be valid")
13592        };
13593        let mut coeff_0_map = HashMap::new();
13594        for poly in 0..2 {
13595            coeff_0_map.insert(poly as PolynomialIndex, identity_element);
13596        }
13597        // The dummy constant terms sum to the identity, whose discrete logarithm is zero, so an
13598        // honest proof of knowledge over it is constructible — the Exit refuses the cycle without
13599        // one.
13600        let zero = <PixScalar<HoprPixSpec> as Field>::ZERO;
13601        let identity_proof = HoprPixCommitmentProof::from(
13602            SsaCommitmentProof::<HoprPixSpec>::prove(&ssa_id, &zero, &PixGroup::<HoprPixSpec>::default())
13603                .expect("identity proof must be constructible"),
13604        );
13605        mgr.handle_ssa_commit(
13606            alice_pseudonym,
13607            SsaClientCommitmentMessage {
13608                session_id: alice_pseudonym,
13609                ssa_index: SsaIndex::MIN,
13610                coefficient_index: 0,
13611                commitment_proof: Some(identity_proof),
13612                coefficient_commitments: coeff_0_map,
13613            },
13614        )
13615        .await?;
13616
13617        // Deliver coefficient 1 (linear terms across all polynomials).
13618        let mut coeff_1_map = HashMap::new();
13619        for poly in 0..2 {
13620            coeff_1_map.insert(poly as PolynomialIndex, identity_element);
13621        }
13622        mgr.handle_ssa_commit(
13623            alice_pseudonym,
13624            SsaClientCommitmentMessage {
13625                session_id: alice_pseudonym,
13626                ssa_index: SsaIndex::MIN,
13627                coefficient_index: 1,
13628                commitment_proof: None,
13629                coefficient_commitments: coeff_1_map,
13630            },
13631        )
13632        .await?;
13633
13634        // The first coefficient commitment should trigger DepositNeeded.
13635        pin_mut!(pix_events_rx);
13636        let event = tokio::time::timeout(std::time::Duration::from_secs(2), pix_events_rx.next())
13637            .await
13638            .map_err(|e| anyhow::anyhow!("timeout waiting for pix event: {e}"))?
13639            .ok_or_else(|| anyhow::anyhow!("pix_events_rx closed without emitting an event"))?;
13640
13641        assert!(matches!(
13642            event,
13643            HoprSessionOutPixEvent::DepositNeeded(AgreedSsaQuota { ssa_id: ref received_ssa_id, .. }, _)
13644            if received_ssa_id == &ssa_id
13645        ));
13646
13647        let HoprSessionOutPixEvent::DepositNeeded(quota, _) = event else {
13648            unreachable!();
13649        };
13650        assert_eq!(quota.quota_per_ssa, pix_params_to_quota(&small_pix_params()));
13651
13652        bob_sender.close_channel();
13653        bob_handle.await??;
13654
13655        Ok(())
13656    }
13657
13658    /// Verifies that a PIX Session whose Entry never answers the `SsaRequest` is closed when the
13659    /// commitment deadline expires.
13660    ///
13661    /// This used to be spelled as a deposit-timeout test, with `max_ssa_delivery_time: 0` and a
13662    /// 50 ms `max_deposit_wait`. A zero delivery time makes the *commitment* deadline fire
13663    /// immediately, so the assertion held whatever the deposit deadline did — it would have passed
13664    /// with that deadline removed entirely. The deposit deadline cannot be reached from this
13665    /// fixture at all: arming it needs a `CommitmentVerified`, and a mock transport has no Entry to
13666    /// send one. That path is covered end-to-end by `hopr-lib`'s `deposit_timeout_closes_session`.
13667    ///
13668    /// So `max_deposit_wait` is set far out of reach here, leaving the commitment deadline as the
13669    /// only thing that can close the Session.
13670    #[test_log::test(tokio::test)]
13671    async fn session_is_closed_when_the_entry_never_commits() -> anyhow::Result<()> {
13672        use std::time::Duration;
13673
13674        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
13675        use hopr_protocol_start::StartInitiation;
13676
13677        let ssa_gen_config = SsaGeneratorConfig {
13678            polynomials_per_ssa: 2,
13679            threshold: 2,
13680            surplus_shares: 1,
13681        };
13682
13683        let (pix_toolbox, _) = PixToolbox::new(
13684            SsaShareGenerator::new(ssa_gen_config).into(),
13685            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13686        );
13687
13688        let mgr = SessionManager::new(SessionManagerConfig {
13689            pix_config: IncomingSessionPixConfig {
13690                quota_range: 0..=1024 * 1024 * 1024,
13691                supervision: SupervisorConfig {
13692                    // Short, so the deadline under test fires quickly.
13693                    max_ssa_delivery_time: Duration::from_millis(50),
13694                    // Scaled with it, since it has to stay under the delivery deadline. Nothing arms
13695                    // it here — the Entry sends no commitment at all, so there is no scope to
13696                    // re-request and the deadline under test is the only one that can fire.
13697                    commitment_recommit_interval: Duration::from_millis(10),
13698                    // Far out of reach, so it cannot be what closes the Session.
13699                    max_deposit_wait: Duration::from_secs(3600),
13700                    ..Default::default()
13701                },
13702                ..Default::default()
13703            },
13704            ..Default::default()
13705        });
13706
13707        let mut bob_transport = MockMsgSender::new();
13708        // handle_incoming_session_initiation sends SessionEstablished + SsaRequest (2 messages).
13709        bob_transport
13710            .expect_send_message()
13711            .returning(|_, _| Box::pin(async { Ok(()) }));
13712        bob_transport
13713            .expect_send_message()
13714            .returning(|_, _| Box::pin(async { Ok(()) }));
13715
13716        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13717        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13718        let _notifications = tokio::spawn(async move {
13719            pin_mut!(new_session_rx);
13720            while let Some(_session) = new_session_rx.next().await {}
13721        });
13722        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox.clone()), None)?;
13723
13724        let alice_pseudonym = HoprPseudonym::random();
13725
13726        mgr.handle_incoming_session_initiation(
13727            alice_pseudonym,
13728            StartInitiation {
13729                challenge: MIN_CHALLENGE,
13730                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13731                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13732                additional_data: small_pix_additional_data(),
13733            },
13734        )
13735        .await?;
13736
13737        // Session is active after establishment.
13738        assert_eq!(vec![alice_pseudonym], mgr.active_sessions());
13739
13740        // The supervisor arms the commitment deadline once the `SsaRequest` goes out and closes the
13741        // Session 50 ms later; the teardown that follows is asynchronous.
13742        tokio::time::timeout(Duration::from_secs(2), async {
13743            while mgr.num_active_sessions() > 0 {
13744                tokio::time::sleep(Duration::from_millis(10)).await;
13745            }
13746        })
13747        .await
13748        .context("session should be closed once the commitment deadline expires")?;
13749
13750        assert!(mgr.active_sessions().is_empty());
13751
13752        bob_sender.close_channel();
13753        bob_handle.await??;
13754
13755        Ok(())
13756    }
13757
13758    /// The surplus an Entry offers must survive the round trip: stored by the Exit, then echoed
13759    /// back in the `SsaRequest` it sends.
13760    ///
13761    /// It is the one negotiated value with no other consumer — it is not part of the priced quota,
13762    /// so no quota check would notice it going missing, and the Exit does not act on it yet. Every
13763    /// other dimension is pinned several times over by the checks around it.
13764    #[test_log::test(tokio::test)]
13765    async fn exit_stores_and_echoes_the_offered_surplus() -> anyhow::Result<()> {
13766        use hopr_protocol_pix::SsaReconstructorConfig;
13767        use hopr_protocol_start::StartInitiation;
13768
13769        // Distinct from every default, so a value read from local config instead of the wire is
13770        // visibly wrong rather than accidentally right.
13771        const OFFERED_SURPLUS: u8 = 37;
13772
13773        let pix_toolbox = pix_toolbox_with_pool(
13774            SsaShareGenerator::new(SsaGeneratorConfig {
13775                polynomials_per_ssa: 2,
13776                threshold: 2,
13777                surplus_shares: TEST_SURPLUS_SHARES,
13778            })
13779            .into(),
13780            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13781        );
13782
13783        let mgr = SessionManager::new(SessionManagerConfig {
13784            pix_config: IncomingSessionPixConfig {
13785                quota_range: 0..=1024 * 1024 * 1024,
13786                ..Default::default()
13787            },
13788            ..Default::default()
13789        });
13790
13791        let echoed: Arc<std::sync::Mutex<Vec<PixParams>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
13792        let echoed_clone = echoed.clone();
13793        let mut bob_transport = MockMsgSender::new();
13794        bob_transport.expect_send_message().returning(move |_, data| {
13795            if let Ok(HoprStartProtocol::SsaRequest(req)) = HoprStartProtocol::try_from(data.data)
13796                && let Ok(params) = req.dimensions()
13797            {
13798                echoed_clone.lock().unwrap().push(params);
13799            }
13800            Box::pin(async { Ok(()) })
13801        });
13802
13803        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13804        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13805        let _notifications = tokio::spawn(async move {
13806            pin_mut!(new_session_rx);
13807            while let Some(_session) = new_session_rx.next().await {}
13808        });
13809        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
13810
13811        let alice_pseudonym = HoprPseudonym::random();
13812        mgr.handle_incoming_session_initiation(
13813            alice_pseudonym,
13814            StartInitiation {
13815                challenge: MIN_CHALLENGE,
13816                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13817                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13818                additional_data: pix_additional_data(2, 2, OFFERED_SURPLUS),
13819            },
13820        )
13821        .await?;
13822
13823        let expected = PixParams::try_new(2, 2, OFFERED_SURPLUS, LOCAL_PIX_SUITE)?;
13824
13825        let slot = mgr.sessions.get(&alice_pseudonym).context("session must exist")?;
13826        let ssa_state = slot.current_ssa_state.get().context("pix state must be set")?;
13827        assert_eq!(
13828            expected, ssa_state.params,
13829            "the Exit must keep the surplus the Entry offered, not its own"
13830        );
13831
13832        // The echo does not go out inline: the supervisor's opening `RequestSsa` is carried out by
13833        // the action driver spawned once the Session is published, which is what keeps
13834        // `SessionEstablished` ahead of `SsaRequest` on the wire.
13835        tokio::time::timeout(Duration::from_secs(1), async {
13836            while echoed.lock().unwrap().is_empty() {
13837                tokio::time::sleep(Duration::from_millis(10)).await;
13838            }
13839        })
13840        .await
13841        .context("the supervisor's opening SsaRequest never reached the wire")?;
13842
13843        bob_sender.close_channel();
13844        bob_handle.await??;
13845
13846        let echoed = echoed.lock().unwrap().clone();
13847        assert_eq!(
13848            vec![expected],
13849            echoed,
13850            "the SsaRequest must echo back exactly what was offered"
13851        );
13852
13853        Ok(())
13854    }
13855
13856    /// Verifies that `check_pix_params` rejects out-of-bounds parameters that pass the
13857    /// quota-range check but exceed the protocol limits.
13858    ///
13859    /// This is a regression test for the incentive-bypass fix (round-1 finding #1).
13860    #[test_log::test(tokio::test)]
13861    async fn check_pix_params_must_reject_invalid_bounds() -> anyhow::Result<()> {
13862        let mgr =
13863            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(SessionManagerConfig {
13864                pix_config: IncomingSessionPixConfig {
13865                    quota_range: 0..=10_000_000_000_000,
13866                    ..Default::default()
13867                },
13868                ..Default::default()
13869            });
13870
13871        let offer = |additional_data: u64| StartInitiation {
13872            challenge: 0,
13873            target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse().unwrap())),
13874            capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13875            additional_data: into_initiation_additional_data(additional_data),
13876        };
13877        // Packed by hand rather than through `pix_additional_data`, because that helper refuses to
13878        // build the very values under test. The layout it mirrors is pinned in `PixParams`' own
13879        // tests. The suite is this build's, so that the dimension cases below fail on their
13880        // dimensions rather than on a curve mismatch — that rejection has its own test.
13881        let packed = |polys: u16, shares: u8, surplus: u8| {
13882            ((LOCAL_PIX_SUITE as u64) << 62)
13883                | ((polys as u64) << 48)
13884                | ((shares as u64) << 40)
13885                | ((surplus as u64) << 32)
13886        };
13887
13888        // polys_per_ssa > MAX_POLYS_PER_SSA (16192), and zero, with a valid quota -> should reject
13889        for polys in [0, MAX_POLYS_PER_SSA + 1, u16::MAX] {
13890            assert!(
13891                decode_and_check(&mgr, &offer(packed(polys, 128, 0))).is_none(),
13892                "should reject polys_per_ssa {polys}"
13893            );
13894        }
13895
13896        // shares_per_poly < MIN_POLY_THRESHOLD with a valid quota -> should reject. There is no
13897        // matching upper-bound case: the threshold is a byte on the wire, so `MAX_POLY_THRESHOLD`
13898        // cannot be exceeded by anything a peer is able to send.
13899        for shares in [0, 1] {
13900            assert!(
13901                decode_and_check(&mgr, &offer(packed(8192, shares, 0))).is_none(),
13902                "should reject shares_per_poly {shares}"
13903            );
13904        }
13905
13906        // Valid params should still be accepted, and arrive intact — including the surplus, which
13907        // no other check looks at and which would therefore be free to go missing.
13908        let (accepted, batch_size) =
13909            decode_and_check(&mgr, &offer(packed(8192, 128, 37))).context("should accept valid params")?;
13910        assert_eq!(PixParams::try_new(8192, 128, 37, LOCAL_PIX_SUITE)?, accepted);
13911        assert_eq!(1, batch_size);
13912
13913        // Every surplus a byte can hold is legal.
13914        for surplus in [0, 1, u8::MAX] {
13915            assert_eq!(
13916                Some((PixParams::try_new(8192, 128, surplus, LOCAL_PIX_SUITE)?, 1)),
13917                decode_and_check(&mgr, &offer(packed(8192, 128, surplus)))
13918            );
13919        }
13920
13921        Ok(())
13922    }
13923
13924    /// The Exit refuses a client whose PIX curve suite is not the one this node was built for.
13925    ///
13926    /// The curve is a build-time property on both sides and is not negotiated, so the only two
13927    /// outcomes are "same suite" and "refused". This matters because it happens *before* the Exit
13928    /// commits: every later PIX field — each coefficient commitment, the proof of knowledge — is
13929    /// sized by the curve, so a mismatch that got past here would surface as undecodable Start
13930    /// traffic on a Session both sides believed they had established.
13931    ///
13932    /// The quota range is wide open, so nothing but the suite can be doing the rejecting.
13933    #[test_log::test(tokio::test)]
13934    async fn check_pix_params_must_refuse_a_foreign_curve_suite() -> anyhow::Result<()> {
13935        let mgr =
13936            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(SessionManagerConfig {
13937                pix_config: IncomingSessionPixConfig {
13938                    quota_range: 0..=10_000_000_000_000,
13939                    ..Default::default()
13940                },
13941                ..Default::default()
13942            });
13943
13944        let offer = |suite: hopr_protocol_pix::PixSuite| StartInitiation {
13945            challenge: 0,
13946            target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse().unwrap())),
13947            capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13948            additional_data: into_initiation_additional_data(
13949                PixParams::try_new(8192, 128, 37, suite)
13950                    .expect("test dimensions must be valid")
13951                    .into_additional_data(0),
13952            ),
13953        };
13954
13955        let foreign = match LOCAL_PIX_SUITE {
13956            hopr_protocol_pix::PixSuite::BabyJubJub => hopr_protocol_pix::PixSuite::Secp256k1,
13957            hopr_protocol_pix::PixSuite::Secp256k1 => hopr_protocol_pix::PixSuite::BabyJubJub,
13958        };
13959
13960        assert!(
13961            decode_and_check(&mgr, &offer(foreign)).is_none(),
13962            "a client offering {foreign} must be refused by a {LOCAL_PIX_SUITE} Exit"
13963        );
13964        assert!(
13965            decode_and_check(&mgr, &offer(LOCAL_PIX_SUITE)).is_some(),
13966            "and the same dimensions on this build's own curve must still be accepted"
13967        );
13968
13969        // A suite identifier no curve claims is refused as well, rather than read as a third curve.
13970        let unknown = StartInitiation {
13971            challenge: 0,
13972            target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse().unwrap())),
13973            capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13974            additional_data: into_initiation_additional_data(
13975                (0b11u64 << 62) | (8192u64 << 48) | (128u64 << 40) | (37u64 << 32),
13976            ),
13977        };
13978        assert!(
13979            decode_and_check(&mgr, &unknown).is_none(),
13980            "an unknown suite identifier must be refused"
13981        );
13982
13983        Ok(())
13984    }
13985
13986    /// Verifies that dispatching too many `UnverifiableShare` events closes the session.
13987    #[test_log::test(tokio::test)]
13988    async fn too_many_unverifiable_shares_closes_session() -> anyhow::Result<()> {
13989        let params = small_pix_params();
13990        let quota = pix_params_to_quota(&params);
13991        let ssa_gen_config = SsaGeneratorConfig {
13992            polynomials_per_ssa: 2,
13993            threshold: 2,
13994            surplus_shares: 1,
13995        };
13996
13997        let pix_toolbox = pix_toolbox_with_pool(
13998            SsaShareGenerator::new(ssa_gen_config).into(),
13999            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
14000        );
14001
14002        let mgr =
14003            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(SessionManagerConfig {
14004                pix_config: IncomingSessionPixConfig {
14005                    quota_range: quota..=quota,
14006                    max_live_cycle_bytes: max_cycle_budget_for_quota(quota, 1),
14007                    supervision: SupervisorConfig {
14008                        max_deposit_wait: Duration::from_secs(1),
14009                        ..Default::default()
14010                    },
14011                    ..Default::default()
14012                },
14013                ..Default::default()
14014            });
14015
14016        let mut bob_transport = MockMsgSender::new();
14017        bob_transport
14018            .expect_send_message()
14019            .returning(|_, _| Box::pin(async { Ok(()) }));
14020        bob_transport
14021            .expect_send_message()
14022            .returning(|_, _| Box::pin(async { Ok(()) }));
14023
14024        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
14025        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
14026        let _notifications = tokio::spawn(async move {
14027            pin_mut!(new_session_rx);
14028            while let Some(_session) = new_session_rx.next().await {}
14029        });
14030        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox.clone()), None)?;
14031
14032        let alice_pseudonym = HoprPseudonym::random();
14033
14034        mgr.handle_incoming_session_initiation(
14035            alice_pseudonym,
14036            StartInitiation {
14037                challenge: MIN_CHALLENGE,
14038                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
14039                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
14040                additional_data: into_initiation_additional_data(params.into_additional_data(0)),
14041            },
14042        )
14043        .await?;
14044
14045        // Session is active
14046        assert_eq!(vec![alice_pseudonym], mgr.active_sessions());
14047
14048        let ssa_id = SsaId::new(alice_pseudonym, SsaIndex::new(1).expect("non-zero"));
14049
14050        // Exactly one event, reporting the absolute total the reconstructor would carry for a single
14051        // failed polynomial. There is no tolerance to exhaust first.
14052        let result = mgr
14053            .dispatch_pix_event(HoprSessionInPixEvent::UnverifiableShares {
14054                ssa_id,
14055                observed_total: 1,
14056                peer: crate::supervision::test_peer(),
14057            })
14058            .await;
14059        // Forwarding succeeds even for the event that closes: the supervisor decides, and it does so
14060        // after the send has been accepted.
14061        assert!(result.is_ok(), "dispatch_pix_event should not return an error");
14062
14063        // The supervisor's `Close` reaches the driver asynchronously, so the teardown it triggers
14064        // is observed rather than assumed.
14065        tokio::time::timeout(Duration::from_secs(1), async {
14066            while mgr.num_active_sessions() > 0 {
14067                tokio::time::sleep(Duration::from_millis(10)).await;
14068            }
14069        })
14070        .await
14071        .context("session was not closed after too many unverifiable shares")?;
14072
14073        assert!(mgr.active_sessions().is_empty());
14074
14075        bob_sender.close_channel();
14076        bob_handle.await??;
14077
14078        Ok(())
14079    }
14080
14081    /// Verifies that `allocate_session_slot` signals waiters via `.send(())` rather than
14082    /// dropping the senders. The `Ok(Ok(()))` path in `handle_ssa_request` must be live.
14083    #[test_log::test(tokio::test)]
14084    async fn allocate_session_slot_must_signal_waiters_not_cancel_them() -> anyhow::Result<()> {
14085        let session_id = HoprPseudonym::random();
14086        let mgr = SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
14087
14088        // Register a waiter before the slot is allocated
14089        let (tx, mut rx) = oneshot::channel::<()>();
14090        mgr.slot_allocated
14091            .lock()
14092            .unwrap_or_else(|e| e.into_inner())
14093            .entry(session_id)
14094            .or_default()
14095            .push(tx);
14096
14097        let (session_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
14098
14099        // Allocate the slot — this should signal the waiter
14100        let guard = mgr.allocate_session_slot(
14101            session_id,
14102            SessionSlot {
14103                session_tx,
14104                routing_opts: DestinationRouting::Return(SurbMatcher::Pseudonym(session_id)),
14105                abort_handles: Default::default(),
14106                surb_mgmt: Arc::new(BalancerStateValues::from(SurbBalancerConfig::default())),
14107                surb_estimator: Default::default(),
14108                current_ssa_state: Default::default(),
14109                pix_supervisor: Default::default(),
14110                pix_egress_gate: Default::default(),
14111                pix_fill: Default::default(),
14112                returned_packets: Default::default(),
14113                cycle_budget: None,
14114            },
14115        );
14116        assert!(guard.is_some(), "slot allocation must succeed");
14117
14118        // The waiter should receive Some(()) — the direct signal, not Canceled
14119        assert!(
14120            matches!(rx.try_recv(), Ok(Some(()))),
14121            "waiter must be signaled with Ok(()), not canceled"
14122        );
14123
14124        guard.unwrap().commit();
14125
14126        Ok(())
14127    }
14128
14129    /// What the Exit reports back to its pool must be what the Exit sent.
14130    ///
14131    /// The pool answers over a channel and nothing stops it answering for an SSA that is not in the
14132    /// batch, or for another Session entirely. Those replies are dropped from the wire; they must be
14133    /// dropped from the Exit's own memory of the batch too. Keying whole replies by `id.ssa_index()`
14134    /// did not do that: a foreign reply naming an in-batch index displaced the good entry, and
14135    /// `DepositNeeded` then handed the pool back deposit data belonging to another Session.
14136    #[test_log::test(tokio::test)]
14137    async fn a_foreign_deposit_reply_must_not_displace_what_the_exit_sent() -> anyhow::Result<()> {
14138        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
14139
14140        let session_id: SessionId = HoprPseudonym::random();
14141        let other_session: SessionId = HoprPseudonym::random();
14142        let batch = [SsaIndex::MIN];
14143
14144        let (pix_toolbox, pix_events) = PixToolbox::new(
14145            SsaShareGenerator::new(SsaGeneratorConfig {
14146                polynomials_per_ssa: 2,
14147                threshold: 2,
14148                surplus_shares: 1,
14149            })
14150            .into(),
14151            Arc::new(SsaReconstructor::new(SsaReconstructorConfig::default())),
14152        );
14153        drop(pix_events);
14154        let _ = pix_toolbox;
14155
14156        let ssa_state = SessionSsaState::new(PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE)?);
14157
14158        // What the pool sent: the right entry, then a same-index entry belonging to another Session.
14159        let replies = vec![
14160            PixDepositData {
14161                id: PixAddressId::new(&session_id, SsaIndex::MIN),
14162                data: b"mine".as_slice().into(),
14163            },
14164            PixDepositData {
14165                id: PixAddressId::new(&other_session, SsaIndex::MIN),
14166                data: b"someone else's".as_slice().into(),
14167            },
14168        ];
14169
14170        // Only the validated map is remembered, which is the whole point.
14171        let validated = deposit_data_for_batch(&session_id, &batch, replies)?;
14172        ssa_state.remember_sent_deposit_data(&validated);
14173
14174        let recalled = ssa_state.take_sent_deposit_data(&session_id, SsaIndex::MIN);
14175        assert_eq!(
14176            b"mine".as_slice(),
14177            recalled.data.as_ref(),
14178            "the Exit must recall the payload it actually sent"
14179        );
14180        assert_eq!(
14181            PixAddressId::new(&session_id, SsaIndex::MIN),
14182            recalled.id,
14183            "the id must name this Session and this index"
14184        );
14185
14186        // Consumed exactly once; a second cycle would see empty rather than a stale entry.
14187        assert!(
14188            ssa_state.take_sent_deposit_data(&session_id, SsaIndex::MIN).is_empty(),
14189            "taking must remove the entry"
14190        );
14191
14192        Ok(())
14193    }
14194
14195    /// Runs [`request_deposit_data`](SessionManager::request_deposit_data) against a pool that answers
14196    /// with exactly what `script` returns, in that order.
14197    ///
14198    /// `script` is handed the ids the Exit asked for and returns the replies the pool will send, one
14199    /// at a time — which is what makes reply *ordering* expressible, and ordering is the only thing
14200    /// the tests below differ on from a run that never had a problem.
14201    ///
14202    /// The pool holds its sender open after the script runs out, rather than dropping it and ending
14203    /// the stream. A pool that closes the channel ends the read all by itself, which would let a test
14204    /// pass on that alone — the reason the collection stopped is exactly what these tests are about,
14205    /// so the only things left to stop it are the ones under test.
14206    async fn request_deposit_data_answered_with(
14207        session_id: SessionId,
14208        batch: &[SsaIndex],
14209        script: impl FnOnce(Vec<PixAddressId>) -> Vec<PixDepositData> + Send + 'static,
14210    ) -> errors::Result<HoprPixDepositData> {
14211        let (pix_toolbox, pix_events) = PixToolbox::new(
14212            SsaShareGenerator::new(SsaGeneratorConfig {
14213                polynomials_per_ssa: 2,
14214                threshold: 2,
14215                surplus_shares: 1,
14216            })
14217            .into(),
14218            Arc::new(SsaReconstructor::new(SsaReconstructorConfig::default())),
14219        );
14220
14221        tokio::task::spawn(async move {
14222            let mut events = Box::pin(pix_events);
14223            if let Some(HoprSessionOutPixEvent::DepositDataRequest(request)) = events.next().await {
14224                let mut created = request.deposit_data_created;
14225                for reply in script(request.deposit_ids) {
14226                    if created.send(reply).await.is_err() {
14227                        return;
14228                    }
14229                }
14230                // Held open, not dropped — see above. The task is torn down with the test's runtime.
14231                std::future::pending::<()>().await;
14232            }
14233        });
14234
14235        SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::request_deposit_data(
14236            &pix_toolbox,
14237            session_id,
14238            batch,
14239        )
14240        .await
14241    }
14242
14243    /// A reply the batch has no place for must not cost a reply that was asked for.
14244    ///
14245    /// The pool answers over one channel, and nothing about that channel separates a reply for this
14246    /// batch from another Session's. Reading a fixed number of *items* — one per requested SSA — let
14247    /// the first kind spend a slot belonging to the second, so a pool that answered every SSA and
14248    /// merely spoke out of turn lost the Session on the answer that got truncated.
14249    ///
14250    /// Ordering is the whole test: the same foreign reply arriving *after* the batch was complete
14251    /// never cost anything, because the collection had already stopped.
14252    #[test_log::test(tokio::test)]
14253    async fn a_foreign_reply_before_a_valid_one_must_not_truncate_the_batch() -> anyhow::Result<()> {
14254        let session_id: SessionId = HoprPseudonym::random();
14255        let other_session: SessionId = HoprPseudonym::random();
14256        let batch = [SsaIndex::MIN, SsaIndex::new(2).expect("non-zero")];
14257
14258        let collected = request_deposit_data_answered_with(session_id, &batch, move |ids| {
14259            std::iter::once(PixDepositData {
14260                id: PixAddressId::new(&other_session, SsaIndex::MIN),
14261                data: b"someone else's".as_slice().into(),
14262            })
14263            .chain(ids.into_iter().map(|id| PixDepositData {
14264                id,
14265                data: b"mine".as_slice().into(),
14266            }))
14267            .collect()
14268        })
14269        .await
14270        .context("a pool that answered every requested SSA must not fail the request")?;
14271
14272        let validated = deposit_data_for_batch(&session_id, &batch, collected)
14273            .context("every SSA in the batch must come out with an entry")?;
14274
14275        assert_eq!(batch.len(), validated.len(), "the whole batch must be covered");
14276        for index in batch {
14277            assert_eq!(
14278                b"mine".as_slice(),
14279                validated[&index].0.as_ref(),
14280                "index {index} must carry this Session's payload"
14281            );
14282        }
14283
14284        Ok(())
14285    }
14286
14287    /// A second answer for an SSA already answered must not cost an SSA still outstanding.
14288    ///
14289    /// Same failure as the foreign reply above, from the other direction: the repeat is a reply the
14290    /// batch has no place for, and counting items charged it against the one SSA that had not been
14291    /// answered yet. Which copy of the repeat wins is not what is under test here — that
14292    /// [`deposit_data_for_batch`] takes the later one is incidental — only that the reply after it
14293    /// still arrives.
14294    #[test_log::test(tokio::test)]
14295    async fn a_duplicate_reply_before_a_valid_one_must_not_truncate_the_batch() -> anyhow::Result<()> {
14296        let session_id: SessionId = HoprPseudonym::random();
14297        let batch = [SsaIndex::MIN, SsaIndex::new(2).expect("non-zero")];
14298
14299        let collected = request_deposit_data_answered_with(session_id, &batch, |ids| {
14300            let mut replies = ids
14301                .iter()
14302                .map(|id| PixDepositData {
14303                    id: *id,
14304                    data: vec![id.ssa_index().get() as u8].into_boxed_slice(),
14305                })
14306                .collect::<Vec<_>>();
14307            // The repeat of the first answer is inserted before the last one, so counting items
14308            // would have stopped on it and never read the SSA it was hiding.
14309            replies.insert(1, replies[0].clone());
14310            replies
14311        })
14312        .await
14313        .context("a pool that repeated itself but answered every SSA must not fail the request")?;
14314
14315        let validated = deposit_data_for_batch(&session_id, &batch, collected)
14316            .context("every SSA in the batch must come out with an entry")?;
14317
14318        assert_eq!(batch.len(), validated.len(), "the whole batch must be covered");
14319        for index in batch {
14320            assert_eq!(
14321                &[index.get() as u8],
14322                validated[&index].0.as_ref(),
14323                "index {index} must carry its own payload"
14324            );
14325        }
14326
14327        Ok(())
14328    }
14329
14330    /// Tolerating replies the batch has no place for must not become waiting for the deadline.
14331    ///
14332    /// Reading until every requested id is answered has no natural end when the pool never answers
14333    /// one, so the read is capped at twice the requested count. A pool that talks without ever
14334    /// closing the gap is cut off there and the request fails on the gap — which is the same
14335    /// verdict the deadline would reach, minus the [`DEPOSIT_DATA_REQUEST_TIMEOUT`] spent reaching
14336    /// it, with the Session held open throughout.
14337    #[test_log::test(tokio::test)]
14338    async fn a_pool_answering_only_noise_must_fail_on_the_cap_not_the_deadline() -> anyhow::Result<()> {
14339        let session_id: SessionId = HoprPseudonym::random();
14340        let other_session: SessionId = HoprPseudonym::random();
14341        let batch = [SsaIndex::MIN, SsaIndex::new(2).expect("non-zero")];
14342
14343        // Exactly the cap, and not one of them is for this Session.
14344        let noise = move |ids: Vec<PixAddressId>| {
14345            ids.iter()
14346                .flat_map(|id| {
14347                    std::iter::repeat_n(
14348                        PixDepositData {
14349                            id: PixAddressId::new(&other_session, id.ssa_index()),
14350                            data: b"someone else's".as_slice().into(),
14351                        },
14352                        2,
14353                    )
14354                })
14355                .collect()
14356        };
14357
14358        // Well under the deadline: the point is that the cap ends the read, not the clock.
14359        let result = timeout(
14360            Duration::from_secs(1),
14361            request_deposit_data_answered_with(session_id, &batch, noise),
14362        )
14363        .await
14364        .context("the cap must end the read long before DEPOSIT_DATA_REQUEST_TIMEOUT")?;
14365
14366        assert!(
14367            matches!(
14368                result,
14369                Err(TransportSessionError::Manager(SessionManagerError::MissingDepositData(
14370                    _
14371                )))
14372            ),
14373            "a pool that answered none of the requested SSAs must fail the request, got {result:?}"
14374        );
14375
14376        Ok(())
14377    }
14378
14379    /// The reply that lands exactly on the cap still counts.
14380    ///
14381    /// One reply earlier and the batch would be complete without the cap ever mattering, so this is
14382    /// the only arrangement that tells `take(2 * requested)` apart from `take(2 * requested - 1)`:
14383    /// every requested SSA is answered, but the last answer is the last reply the read will accept.
14384    #[test_log::test(tokio::test)]
14385    async fn the_last_reply_the_cap_allows_must_still_complete_the_batch() -> anyhow::Result<()> {
14386        let session_id: SessionId = HoprPseudonym::random();
14387        let other_session: SessionId = HoprPseudonym::random();
14388        let batch = [SsaIndex::MIN, SsaIndex::new(2).expect("non-zero")];
14389
14390        let collected = request_deposit_data_answered_with(session_id, &batch, move |ids| {
14391            // One unusable reply per requested id, then the answers themselves — `2 * requested`
14392            // replies in total, with the one that closes the gap sitting on the boundary.
14393            ids.iter()
14394                .map(|id| PixDepositData {
14395                    id: PixAddressId::new(&other_session, id.ssa_index()),
14396                    data: b"someone else's".as_slice().into(),
14397                })
14398                .chain(ids.iter().map(|id| PixDepositData {
14399                    id: *id,
14400                    data: vec![id.ssa_index().get() as u8].into_boxed_slice(),
14401                }))
14402                .collect()
14403        })
14404        .await
14405        .context("the answer landing on the cap must be read, not cut off by it")?;
14406
14407        let validated = deposit_data_for_batch(&session_id, &batch, collected)
14408            .context("every SSA in the batch must come out with an entry")?;
14409
14410        assert_eq!(batch.len(), validated.len(), "the whole batch must be covered");
14411        for index in batch {
14412            assert_eq!(
14413                &[index.get() as u8],
14414                validated[&index].0.as_ref(),
14415                "index {index} must carry its own payload"
14416            );
14417        }
14418
14419        Ok(())
14420    }
14421
14422    // -----------------------------------------------------------------------
14423    // PIX egress gate pressure
14424    // -----------------------------------------------------------------------
14425
14426    /// The egress path opens exactly one block episode per park, and closes it when the park ends.
14427    ///
14428    /// Asserted here rather than in the gate's own tests because the wiring is what can be wrong:
14429    /// the gate reports a verdict, and this is the only place that turns the verdict into an
14430    /// episode. A permit path that forgot to would leave `hopr_pix_gate_blocks_total` silent while
14431    /// egress was stalled -- exactly the fault the metric exists to surface.
14432    #[tokio::test]
14433    async fn a_parked_egress_packet_opens_and_closes_one_block_episode() -> anyhow::Result<()> {
14434        use crate::supervision::telemetry::probe;
14435
14436        probe::reset();
14437        // Strict prepay: the first packet is refused before anything is served.
14438        let gate = ServiceGate::new(0, 10);
14439
14440        let permit = acquire_egress_permit(
14441            Some(gate.clone()),
14442            DestinationRouting::Return(HoprPseudonym::random().into()),
14443            ApplicationDataOut::with_no_packet_info(ApplicationData::new(
14444                SESSION_APPLICATION_TAG,
14445                b"payload".as_slice(),
14446            )?),
14447        );
14448
14449        assert_eq!(
14450            1,
14451            probe::get("gate_blocks/predeposit_exhausted"),
14452            "the refusal must be counted as one episode beginning"
14453        );
14454        assert_eq!(
14455            0,
14456            probe::get("gate_block_observations/predeposit_exhausted"),
14457            "and must stay open while the packet is still parked"
14458        );
14459
14460        // Funding wakes the parked writer, which ends the episode.
14461        gate.release_service();
14462        timeout(Duration::from_secs(5), permit)
14463            .await
14464            .context("funding must wake the parked packet")?
14465            .context("a funded gate must admit it")?;
14466
14467        assert_eq!(1, probe::get("gate_blocks/predeposit_exhausted"), "still one episode");
14468        assert_eq!(
14469            1,
14470            probe::get("gate_block_observations/predeposit_exhausted"),
14471            "resuming must close the episode exactly once"
14472        );
14473        Ok(())
14474    }
14475
14476    /// Cancelling a parked packet closes its episode too.
14477    ///
14478    /// The case `Drop` exists for: a Session torn down while a writer is parked drops the permit
14479    /// future without ever resolving it, and a timer that only closed on the resume path would be
14480    /// left open for every Session that died blocked.
14481    #[tokio::test]
14482    async fn dropping_a_parked_egress_packet_closes_its_episode() -> anyhow::Result<()> {
14483        use crate::supervision::telemetry::probe;
14484
14485        probe::reset();
14486        let gate = ServiceGate::new(0, 10);
14487
14488        let permit = acquire_egress_permit(
14489            Some(gate.clone()),
14490            DestinationRouting::Return(HoprPseudonym::random().into()),
14491            ApplicationDataOut::with_no_packet_info(ApplicationData::new(
14492                SESSION_APPLICATION_TAG,
14493                b"payload".as_slice(),
14494            )?),
14495        );
14496        // Poll it once so the episode is actually begun inside the future.
14497        assert!(
14498            timeout(Duration::from_millis(50), permit).await.is_err(),
14499            "the packet must park rather than resolve"
14500        );
14501
14502        assert_eq!(1, probe::get("gate_blocks/predeposit_exhausted"));
14503        assert_eq!(
14504            1,
14505            probe::get("gate_block_observations/predeposit_exhausted"),
14506            "a cancelled park must still close its episode"
14507        );
14508        Ok(())
14509    }
14510
14511    /// A poisoned gate is counted as a refusal but opens no episode.
14512    #[tokio::test]
14513    async fn a_closed_gate_refuses_without_opening_an_episode() -> anyhow::Result<()> {
14514        use crate::supervision::telemetry::probe;
14515
14516        probe::reset();
14517        let gate = ServiceGate::new(10, 10);
14518        gate.poison();
14519
14520        let permit = acquire_egress_permit(
14521            Some(gate.clone()),
14522            DestinationRouting::Return(HoprPseudonym::random().into()),
14523            ApplicationDataOut::with_no_packet_info(ApplicationData::new(
14524                SESSION_APPLICATION_TAG,
14525                b"payload".as_slice(),
14526            )?),
14527        );
14528        assert!(permit.await.is_err(), "a poisoned gate must refuse");
14529
14530        assert_eq!(1, probe::get("gate_blocks/closed"));
14531        assert_eq!(
14532            0,
14533            probe::get("gate_block_observations/predeposit_exhausted"),
14534            "a gate that never resumes must not be timed"
14535        );
14536        Ok(())
14537    }
14538
14539    /// An un-gated Session pays nothing and is counted as nothing.
14540    #[tokio::test]
14541    async fn a_session_without_a_gate_is_not_measured() -> anyhow::Result<()> {
14542        use crate::supervision::telemetry::probe;
14543
14544        probe::reset();
14545
14546        acquire_egress_permit(
14547            None,
14548            DestinationRouting::Return(HoprPseudonym::random().into()),
14549            ApplicationDataOut::with_no_packet_info(ApplicationData::new(
14550                SESSION_APPLICATION_TAG,
14551                b"payload".as_slice(),
14552            )?),
14553        )
14554        .await
14555        .context("an un-gated packet passes straight through")?;
14556
14557        assert_eq!(
14558            std::collections::BTreeMap::new(),
14559            probe::non_zero(),
14560            "a Session that negotiated no PIX must contribute to no PIX aggregate"
14561        );
14562        Ok(())
14563    }
14564}