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/// ≈ 160 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/// Standard deviations below an honest Exit's expected returned packets at which the successor gate
378/// sets its bar — see [`successor_required_packets`].
379///
380/// Four put the chance that an Exit losing exactly the tolerated share fraction falls short of the
381/// bar at 3 × 10⁻⁵, and that it falls short by more than the near-miss window — refused without
382/// waiting — at about one in 1.5 million at the deployed split (normal approximation). A shortfall
383/// inside the window still gets [`SSA_SUCCESSOR_SERVICE_WAIT`] for the Session's ongoing traffic to
384/// make it up. The price is ~0.33 % of the boundary added to what an Exit can withhold and still be
385/// funded: the deployed bar moves from 41.3 % of a cycle's packets to 41.0 %.
386const SUCCESSOR_GATE_ARRIVAL_SIGMAS: f64 = 4.0;
387
388/// Returned packets the successor gate demands of an Exit whose boundary is `target` packets, at the
389/// share loss `tolerated_loss` the negotiated split survives.
390///
391/// `floor(max(0, μ − zσ))` for an Exit's arrivals at exactly that loss, `Binomial(target, 1 − L)`:
392/// `μ = target · (1 − L)`, `σ = √(target · L · (1 − L))` and `z` [`SUCCESSOR_GATE_ARRIVAL_SIGMAS`].
393/// A split with no surplus tolerates no loss, so σ is zero and the whole target is demanded exactly.
394///
395/// A tolerance near one floors at one packet, not zero, for any non-zero target: the bound is at most
396/// `μ ≤ target`, so one is never more than the boundary itself. Without it a tiny boundary under a
397/// huge surplus (`128 × (2 + 255)`: μ 10.8, σ 3.2) demands nothing, the near-miss wait never starts
398/// on zero returned packets, and the successor is funded on the Entry's own emission alone. An Exit
399/// that has returned nothing since its first commitment has shown no service whatever split the Entry
400/// chose. The cost is an honest Exit at the tolerated loss returning nothing with probability
401/// `L^target` — 1.5 × 10⁻⁵ at `128 × (2 + 255)`, the order of the 4σ bound's own 3 × 10⁻⁵ — and
402/// it bites only at degenerate cycles of a few dozen packets (`8 × (2 + 255)`: 0.59), where no
403/// statistic means anything.
404///
405/// An `f64` carries both terms to well under a packet below a watermark of 10⁹ cycles.
406fn successor_required_packets(target: u64, tolerated_loss: f64) -> u64 {
407    let bar_floor = target.min(1);
408    let target = target as f64;
409    let delivered = 1.0 - tolerated_loss;
410    let mean = target * delivered;
411    let deviation = (target * tolerated_loss * delivered).sqrt();
412    // The cast floors, the clamped bound being non-negative.
413    let bound = (mean - SUCCESSOR_GATE_ARRIVAL_SIGMAS * deviation).max(0.0) as u64;
414    bound.max(bar_floor)
415}
416
417/// Minimum timeout until an unfinished frame is discarded.
418const MIN_FRAME_TIMEOUT: Duration = Duration::from_millis(10);
419
420/// Hard ceiling on both SSA batch-size knobs, whatever the configuration says.
421///
422/// The current maximum value of 9 is based on the current available pool limitations.
423///
424/// Deliberately far below the wire limit (`StartProtocol::MAX_SSAS_PER_REQUEST`, 27), which only
425/// bounds what can be *decoded*. The real cost is paid on both sides of the exchange and neither is
426/// small at the profiled dimensions:
427///
428/// * Entry: every entry in the batch is a full `new_ssa_commitment` (hundreds of thousands of EC commitments), its own
429///   burst of thousands of `SsaCommit` packets, and its own `ReadyToDeposit` — i.e. its own on-chain deposit.
430/// * Exit: every entry is a live reconstructor cycle, held until that cycle recovers — worst case ≈15.4 MiB at the
431///   deployed dimensions (`hopr_protocol_pix::peak_cycle_bytes`), so ≈138 MiB per Session at this ceiling, before the
432///   pipelining factor. That is what a Session reserves against [`IncomingSessionPixConfig::max_live_cycle_bytes`], so
433///   raising the batch size directly divides how many PIX Sessions the node will accept.
434///
435/// It also bounds the supervisor's deadline scaling: a batch multiplies both
436/// [`max_ssa_delivery_time`](crate::SupervisorConfig::max_ssa_delivery_time) and
437/// [`max_deposit_wait`](crate::SupervisorConfig::max_deposit_wait), and that product is what decides
438/// how long a Session may be served unincentivized.
439///
440/// Both [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request) and
441/// [`SessionManagerConfig::max_ssas_per_ssa_request`] are clamped to `1..=Self` where they are read,
442/// so a programmatically built config that never calls `validate()` cannot exceed it.
443pub const MAX_SSA_BATCH_SIZE: usize = 9;
444
445// The Entry's retention of commitment material has to outlast the batch that material belongs to.
446// `hopr-protocol-pix` cannot see this figure, so it derives its cap from it in prose; this is the
447// half of that derivation which has to hold. A cycle must stay retransmittable at least until its
448// own batch has finished being committed, or a commitment packet lost early in a large batch becomes
449// unrepairable while the rest of the batch is still arriving. Slack beyond one batch is a judgement
450// call, which is why this is an inequality and not the equality the two figures satisfy today.
451const _: () = assert!(
452    hopr_protocol_pix::MAX_RETAINED_COMMITMENT_CYCLES >= MAX_SSA_BATCH_SIZE,
453    "MAX_SSA_BATCH_SIZE exceeds MAX_RETAINED_COMMITMENT_CYCLES, so a cycle can be evicted from the Entry's retention \
454     before its own batch is fully committed and its commitment stops being repairable"
455);
456
457/// How long an Exit waits for the deposit pool to answer a
458/// [`DepositDataRequest`](HoprSessionOutPixEvent::DepositDataRequest) before sending the batch
459/// without what has not arrived.
460pub const DEPOSIT_DATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
461
462/// SSA batches whose reconstructor state can be live on the Exit at the same moment.
463///
464/// Two, enforced at allocation rather than inferred from share-order policy. The supervisor tags
465/// every requested generation and defers a successor while this many generations still own live
466/// reconstructor state; it also checks the equivalent full-batch cycle count charged below. Full
467/// recovery releases cycle state immediately, after which a deferred request is retried.
468///
469/// Used as the pipelining factor when a Session reserves against
470/// [`IncomingSessionPixConfig::max_live_cycle_bytes`].
471pub const MAX_OVERLAPPING_BATCHES: u64 = 2;
472
473/// What a PIX Session at these dimensions costs the node's live-cycle budget.
474///
475/// The worst case one cycle can hold, times every cycle that can be live at once. Both factors come
476/// from configuration rather than observation, because the charge is made before the Session exists.
477/// `ssas_per_request` is clamped for the reason [`SessionManager::new`] clamps it — a
478/// programmatically built config must not be able to understate the reservation and then overrun it.
479pub fn cycle_budget_for(params: &PixParams, ssas_per_request: usize) -> u64 {
480    hopr_protocol_pix::peak_cycle_bytes::<HoprPixSpec>(params)
481        .saturating_mul(ssas_per_request.clamp(1, MAX_SSA_BATCH_SIZE) as u64)
482        .saturating_mul(MAX_OVERLAPPING_BATCHES)
483}
484
485/// The largest reservation any offer inside a `quota_range` ending at `quota_bytes` can produce.
486///
487/// The quota fixes `polys × (threshold + surplus)` but not the split, and the two terms of
488/// [`hopr_protocol_pix::peak_cycle_bytes`] pull opposite ways — the share buffers want a high
489/// threshold, the per-polynomial overhead wants many polynomials — so the maximum is found by
490/// walking the thresholds rather than by a closed form. 254 iterations, once, at config load.
491///
492/// `surplus = 0` throughout, which is what makes each candidate the worst of its threshold: the
493/// surplus is priced into the quota but holds no reconstructor state, so any surplus at all buys
494/// fewer polynomials for the same quota.
495///
496/// Used to reject a [`IncomingSessionPixConfig::max_live_cycle_bytes`] that could never admit even
497/// one Session at the dimensions its own `quota_range` advertises.
498pub fn max_cycle_budget_for_quota(quota_bytes: u64, ssas_per_request: usize) -> u64 {
499    let quota_shares = quota_bytes / PIX_QUOTA_BYTES_PER_SHARE;
500
501    (hopr_protocol_pix::MIN_POLY_THRESHOLD..=hopr_protocol_pix::MAX_POLY_THRESHOLD)
502        .filter_map(|threshold| {
503            let polys = u16::try_from((quota_shares / threshold as u64).min(MAX_POLYS_PER_SSA as u64)).ok()?;
504            PixParams::try_new(polys, threshold, 0, LOCAL_PIX_SUITE).ok()
505        })
506        .map(|params| cycle_budget_for(&params, ssas_per_request))
507        .max()
508        .unwrap_or_default()
509}
510
511/// Default for [`SessionManagerConfig::max_ssas_per_ssa_request`] — how many SSA commitments an Entry
512/// accepts in a single [`SsaServerCommitmentMessage`].
513///
514/// Pipelining needs at most one cycle in flight ahead of the active one, so 2 leaves room for an Exit
515/// batching at the default without turning one inbound packet into an unbounded amount of Entry work
516/// and on-chain deposits.
517pub const DEFAULT_MAX_SSAS_PER_SSA_REQUEST: usize = 2;
518
519/// Default for [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request) —
520/// how many SSAs an Exit may ask for in a single [`SsaServerCommitmentMessage`]. It is the maximum
521/// in dynamic mode and the exact batch in fixed mode.
522///
523/// One, so that the default configuration produces exactly the unbatched exchange: same wire bytes,
524/// same supervisor deadlines, same Start protocol channel capacity.
525pub const DEFAULT_SSAS_PER_SSA_REQUEST: usize = 1;
526
527/// Timeout when sending Start protocol messages to the sink
528const EXTERNAL_SEND_TIMEOUT: Duration = Duration::from_millis(200);
529
530// Needs to use an UnboundedSender instead of oneshot
531// because Moka cache requires the value to be Clone, which oneshot Sender is not.
532// It also cannot be enclosed in an Arc, since calling `send` consumes the oneshot Sender.
533// The Session initiation cache is only present on the Entry (client) side.
534type SessionInitiationCache = moka::sync::Cache<
535    StartChallenge,
536    crossfire::MTx<crossfire::mpsc::One<Result<StartEstablished<SessionId>, StartErrorType<SessionId>>>>,
537>;
538
539/// Handles to streams and tasks spawned by the Session.
540#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
541enum SessionHandles {
542    /// Handle to the stream that facilitates ingress of data from the HOPR network into the Session.
543    Ingress,
544    /// Handle to the process that sends keep-alive messages to the Session recipient (Exit).
545    KeepAlive,
546    /// Handle to the one-shot timer that turns the SURB-level notification on after its first period.
547    ///
548    /// Registered rather than fired and forgotten: it holds an `Arc<PixFillControl>` for as long as
549    /// the period lasts, and no maximum is enforced on that period, so an unregistered task keeps a
550    /// closed Session's fill control alive for however long the operator configured.
551    SurbNotifyDelay,
552    /// Handle to the process that monitors and balances SURBs.
553    Balancer,
554    /// Handle to the task that executes the PIX supervisor's actions.
555    ///
556    /// One per Session rather than one per cycle: the supervisor multiplexes every SSA it tracks
557    /// onto a single action stream.
558    PixActionDriver,
559    /// Handle to the process that awaits the PIX deposit for one SSA.
560    ///
561    /// Carries the inner `SsaIndex` value so that each cycle's observer is independent — pipelining
562    /// must not cancel an earlier cycle's.
563    PixDepositObserver(u32),
564}
565
566impl std::fmt::Display for SessionHandles {
567    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
568        match self {
569            Self::Ingress => write!(f, "Ingress"),
570            Self::KeepAlive => write!(f, "KeepAlive"),
571            Self::SurbNotifyDelay => write!(f, "SurbNotifyDelay"),
572            Self::Balancer => write!(f, "Balancer"),
573            Self::PixActionDriver => write!(f, "PixActionDriver"),
574            Self::PixDepositObserver(idx) => write!(f, "PixDepositObserver({idx})"),
575        }
576    }
577}
578
579/// The PIX dimensions this Session negotiated, on the Exit side.
580///
581/// Once carried the SSA index and a fault counter as well. Both moved to the supervisor: it decides
582/// *when* a cycle is requested, so it is also what allocates the index, and it is what enforces the
583/// fault limit. Leaving a second copy of either here would have meant two authorities for one fact.
584#[derive(Debug)]
585struct SessionSsaState {
586    /// The dimensions this Session negotiated, as they went on the wire.
587    params: PixParams,
588    /// Highest SSA index this Session has had the generator commit to, `0` before the first batch.
589    ///
590    /// A deliberate second copy of a fact the generator already holds, and the only one of the three
591    /// that came back. The generator's own watermark lives in a cache with an idle retention, so
592    /// "absent" there conflates *never committed* with *state discarded* — and those must not be
593    /// treated alike, because the first is the opening batch every Session begins with and the second
594    /// is a Session whose Entry can no longer serve the cycles it already committed to. This copy
595    /// lives exactly as long as the Session does, so it can tell them apart. See the successor gate in
596    /// [`SessionManager::handle_ssa_request`].
597    ///
598    /// Entry-side only; an Exit never commits and leaves it at zero. [`SsaIndex`] is a `NonZero<u32>`,
599    /// which is what makes `0` an unambiguous "none" rather than a sentinel that has to be defended.
600    committed_ssa_watermark: std::sync::atomic::AtomicU32,
601    /// [`SessionSlot::returned_packets`] as it stood when this Session first committed.
602    ///
603    /// The successor gate counts service *since* that instant, not since the Session opened. Before
604    /// the first commitment the generator holds no polynomials, so the SURBs going out carry no
605    /// shares — and an Exit may legitimately be served up to `max_predeposit_packets` of them before
606    /// any deposit exists. Crediting that prefix would let the Exit bank unpaid service against the
607    /// first cycle it *is* paid for. Everything after this point rides a share.
608    returned_at_first_commit: std::sync::atomic::AtomicU64,
609    /// What this Exit sent as deposit data, per SSA index, for cycles whose deposit address it has
610    /// not learned yet.
611    sent_deposit_data: Arc<parking_lot::Mutex<HashMap<SsaIndex, HoprPixDepositPayload>>>,
612}
613
614impl SessionSsaState {
615    pub fn new(params: PixParams) -> Self {
616        Self {
617            params,
618            committed_ssa_watermark: std::sync::atomic::AtomicU32::new(0),
619            returned_at_first_commit: std::sync::atomic::AtomicU64::new(0),
620            sent_deposit_data: Default::default(),
621        }
622    }
623
624    /// Files the deposit data of a batch the Exit is about to send, for
625    /// [`take_sent_deposit_data`](Self::take_sent_deposit_data) to hand back once the matching
626    /// deposit address is known.
627    pub fn remember_sent_deposit_data(&self, deposit_data: &HashMap<SsaIndex, HoprPixDepositPayload>) {
628        let mut sent = self.sent_deposit_data.lock();
629        for (&ssa_index, payload) in deposit_data {
630            sent.insert(ssa_index, payload.clone());
631        }
632    }
633
634    /// Removes and returns what this Exit sent for `ssa_index`, or empty data if it sent none.
635    pub fn take_sent_deposit_data(&self, session_id: &SessionId, ssa_index: SsaIndex) -> PixDepositData {
636        PixDepositData {
637            id: PixAddressId::new(session_id, ssa_index),
638            data: self
639                .sent_deposit_data
640                .lock()
641                .remove(&ssa_index)
642                .map(|payload| payload.0)
643                .unwrap_or_default(),
644        }
645    }
646
647    /// Highest SSA index committed for this Session, `None` before the first batch.
648    #[inline]
649    pub fn committed_watermark(&self) -> Option<SsaIndex> {
650        SsaIndex::new(self.committed_ssa_watermark.load(Ordering::Relaxed))
651    }
652
653    /// Raises the committed-index watermark to `index` if it is higher, taking the returned-packet
654    /// baseline on the first commitment.
655    ///
656    /// `fetch_max` rather than a store: the gate serialises requests per pseudonym, but a Session's
657    /// slot is shared and monotonicity here must not depend on that lock staying where it is. Its
658    /// *previous* value is also what makes the baseline race-free without leaning on that lock —
659    /// exactly one caller can observe a prior watermark of zero, so exactly one stores.
660    #[inline]
661    pub fn note_committed(&self, index: SsaIndex, returned_packets: u64) {
662        if self.committed_ssa_watermark.fetch_max(index.get(), Ordering::Relaxed) == 0 {
663            self.returned_at_first_commit.store(returned_packets, Ordering::Relaxed);
664        }
665    }
666
667    /// Exit → Entry packets received since this Session's first commitment.
668    ///
669    /// Saturating rather than wrapping: `returned_packets` is monotonic and the baseline is a past
670    /// value of it, so an underflow is impossible — and if one ever became possible, reporting zero
671    /// service closes the successor gate rather than opening it.
672    #[inline]
673    pub fn served_since_first_commit(&self, returned_packets: u64) -> u64 {
674        returned_packets.saturating_sub(self.returned_at_first_commit.load(Ordering::Relaxed))
675    }
676
677    /// Data quota one SSA cycle of these dimensions covers.
678    ///
679    /// The whole cycle, surplus included, as per [`pix_params_to_quota`]: every share a cycle emits
680    /// rides one Exit → Entry packet, so all of them are priced.
681    #[inline]
682    pub const fn quota_per_ssa(&self) -> SsaQuota {
683        pix_params_to_quota(&self.params)
684    }
685}
686
687#[derive(Clone)]
688pub(crate) struct SessionSlot {
689    // Sender does not need to be in Arc, because the receiver part is always
690    // wrapped inside DropAbortable wrapper, with abort handle added to `abort_handles`.
691    session_tx: crossfire::MTx<crossfire::mpsc::Array<ApplicationDataIn>>,
692    routing_opts: DestinationRouting,
693    // Additional tasks spawned by the Session.
694    abort_handles: Arc<parking_lot::Mutex<AbortableList<SessionHandles>>>,
695    // Allows reconfiguring of the SURB balancer on-the-fly
696    // Set on both Entry and Exit sides.
697    surb_mgmt: Arc<BalancerStateValues>,
698    // SURB flow updates happening outside of Session protocol
699    // (e.g., due to Start protocol messages).
700    surb_estimator: AtomicSurbFlowEstimator,
701    // Contains currently active SSA for this Session and its quota
702    current_ssa_state: Arc<OnceLock<SessionSsaState>>,
703    /// Handle to this Session's PIX supervisor, on the Exit side of a PIX-enabled Session.
704    ///
705    /// Populated before the [`HoprSession`] is constructed, so the egress adapters below observe a
706    /// gate rather than racing its installation. Empty on the Entry side and on non-PIX Sessions —
707    /// the Exit is authoritative for the lifecycle, so the Entry runs no supervisor.
708    pix_supervisor: Arc<OnceLock<SessionPixSupervisorHandle>>,
709    /// The egress gate every outgoing data packet of a supervised Session must pass.
710    ///
711    /// Held separately from `pix_supervisor` because the egress path touches it per packet and has
712    /// no use for the rest of the handle.
713    pix_egress_gate: Arc<OnceLock<Arc<ServiceGate>>>,
714    /// Steers the Exit's own Exit → Entry keep-alive stream, where the Session has one.
715    ///
716    /// Exit-side, and populated for every PIX Session as well as for any Session with a configured
717    /// `surb_balance_notify_period` — the two producers share one stream, so they share one handle.
718    /// Empty on the Entry side, whose keep-alives are driven by its SURB balancer instead, and on an
719    /// Exit Session that is neither PIX nor notifying, which originates nothing at all.
720    ///
721    /// A `OnceLock` for the same reason the two fields above are: the action driver that sets rates
722    /// on it is spawned after the slot is published, so it must find the handle rather than race its
723    /// installation.
724    pix_fill: Arc<OnceLock<Arc<PixFillControl>>>,
725    /// Exit → Entry Session packets received on this Session, ever.
726    ///
727    /// Entry-side only; stays zero on the Exit, which is the side that *sends* them. This includes
728    /// both Session data and Start-protocol traffic such as the Exit's SURB-level keep-alives. Each
729    /// packet consumed one return SURB, and a SURB carries at most one PIX share which the Exit can
730    /// only decrypt by using it — so this counts shares the Exit has unlocked, measured without
731    /// asking it. That is what makes it usable as a deposit gate: see the successor gate in
732    /// [`SessionManager::handle_ssa_request`].
733    ///
734    /// Deliberately *not* [`surb_estimator`](Self::surb_estimator)`.consumed`, which increments on
735    /// the same event on this side today. That field is documented as an *estimate* feeding the PID
736    /// balancer, and it is only wired when `surb_management` is enabled; this one decides whether
737    /// money is spent and must be neither. Carrying the increment twice is the cheaper mistake: a
738    /// receive path that forgets this counter makes the gate stricter, never laxer.
739    ///
740    /// Credited from exactly two places, and both of them have to stay:
741    ///
742    /// * the `session_rx` inspectors in `new_session`, once per Session data packet handed to the reader — one in each
743    ///   of the balanced and unbalanced branches;
744    /// * the Start-protocol branch of [`dispatch_message`](SessionManager::dispatch_message), which credits every
745    ///   Start-protocol message arriving on an outgoing Session because that traffic bypasses `session_rx` entirely.
746    ///
747    /// The second is what makes the Exit's keep-alives count, and the gate depends on it: an Exit
748    /// whose application has nothing to say can still complete a funded cycle by keep-alive alone, and
749    /// if those packets earned nothing here its successor would be refused as under-served and the
750    /// Session would die on `CommitmentTimeout` with neither side able to name the cause. It is
751    /// credited at the dispatch level rather than inside a per-message handler on purpose — the credit
752    /// is owed for the SURB the packet consumed, which is true whatever the message turns out to be,
753    /// including one this build fails to parse. Adding a second increment inside
754    /// [`handle_keep_alive`](SessionManager::handle_keep_alive) would double-count it.
755    returned_packets: Arc<std::sync::atomic::AtomicU64>,
756    /// This Session's share of the node's live reconstructor-cycle budget.
757    ///
758    /// Exit-side and PIX-only; `None` everywhere else, since nothing else holds cycle state. Behind
759    /// an `Arc` so the budget is returned when the last clone of this slot goes: a slot is cloned
760    /// into the Session cache and into its [`SessionSlotGuard`], so no single one of them can be
761    /// made responsible for the release.
762    ///
763    /// [`close_session`] returns it explicitly, which is what makes the release simultaneous with
764    /// the closure; the `Drop` behind the `Arc` is the backstop for anything that bypasses that
765    /// function. Both are safe to run because the release is idempotent.
766    cycle_budget: Option<Arc<CycleBudgetReservation>>,
767}
768
769/// The Exit's control over the single Exit → Entry keep-alive stream a Session runs.
770///
771/// One stream, two producers with different jobs, and this is what reconciles them:
772///
773/// * the **SURB-level notification**, sent every `surb_balance_notify_period`, which tells the Entry's balancer how
774///   many SURBs the Exit believes it holds. It is the signal that asks for more, so it must go out even when there are
775///   none left;
776/// * **PIX fill**, whose rate the supervisor plans once a second so that a funded cycle completes inside its own
777///   deadline even if the application never sends a byte.
778///
779/// The effective rate is the faster of the two, because both are the same packet: a keep-alive
780/// carrying the SURB level is exactly what fill needs to send, and sending two streams of them would
781/// double the traffic to say the same thing twice. Every shipped Entry already parses it, counts it
782/// as a consumed SURB so its balancer refills, and — since the fix that precedes this — credits it as
783/// service against the successor gate.
784pub(crate) struct PixFillControl {
785    session_id: SessionId,
786    /// The rate limiter of the keep-alive stream this steers.
787    controller: RateController,
788    /// The configured SURB-level notification period, if the node has one.
789    ///
790    /// `None` disables the notification half entirely; the stream then runs at the fill rate alone,
791    /// which is how a PIX Session on a node with `surb_balance_notify_period: None` still fills.
792    notify_period: Option<Duration>,
793    /// SURBs that must remain estimated-available before a *fill* packet is let through.
794    ///
795    /// The *effective* reserve for this Session, not the configured one: the Entry decides how deep
796    /// the Exit's SURB buffer for the Session is, so a reserve above what the Entry ever intends to
797    /// supply would refuse every fill packet forever. See [`effective_surb_reserve`].
798    min_surb_reserve: u64,
799    /// The Session's SURB flow estimate, which is what the reserve is measured against.
800    estimator: AtomicSurbFlowEstimator,
801    state: parking_lot::Mutex<PixFillState>,
802}
803
804struct PixFillState {
805    /// Whether the initial notification delay has elapsed.
806    ///
807    /// The notification stream deliberately waits one period before starting, so a Session that
808    /// closes immediately never sends one. Fill does not wait, and must not be held behind this.
809    notify_started: bool,
810    /// The rate the PIX supervisor last asked for. [`FillRate::ZERO`] when it is not filling.
811    fill: FillRate,
812    /// When a packet was last let through as the SURB-level notification.
813    ///
814    /// The two producers share a stream, so nothing about a packet says which of them it belongs to.
815    /// This is what makes the distinction: one packet per notification period is the notification and
816    /// is exempt from the SURB reserve, and everything above that rate is fill and is not.
817    last_notify_at: Option<Instant>,
818    /// Whether the SURB reserve is currently withholding fill, so `hopr_pix_sessions_stalled` counts
819    /// this Session once per stall rather than once per refused packet.
820    withholding: bool,
821    /// Whether this stream is draining the SURB buffer of a Session that has already been closed.
822    ///
823    /// Set once, by [`PixFillControl::enter_drain`], and never cleared: a drain ends with the
824    /// Session, and the stream with it.
825    draining: bool,
826}
827
828impl PixFillControl {
829    fn new(
830        session_id: SessionId,
831        controller: RateController,
832        notify_period: Option<Duration>,
833        min_surb_reserve: u64,
834        estimator: AtomicSurbFlowEstimator,
835    ) -> Self {
836        Self {
837            session_id,
838            controller,
839            notify_period,
840            // Clamped rather than trusted: a `SessionManager` can be built from a config that never
841            // went through `validate_pix_supervision`, and a zero reserve would let fill spend the
842            // Session's last SURB. `effective_surb_reserve` applies the same floor, so this is only
843            // load-bearing for the tests that construct a control directly.
844            min_surb_reserve: min_surb_reserve.max(1),
845            estimator,
846            state: parking_lot::Mutex::new(PixFillState {
847                notify_started: false,
848                fill: FillRate::ZERO,
849                last_notify_at: None,
850                withholding: false,
851                draining: false,
852            }),
853        }
854    }
855
856    /// Turns the SURB-level notification on, after its initial delay.
857    ///
858    /// A no-op once the Session is draining, for the reason [`enter_drain`](Self::enter_drain)
859    /// gives — and it has to be checked here rather than only there, because the one-shot delay task
860    /// holds a clone of this control and nothing bounds how long an operator may make its period.
861    pub(crate) fn start_notify(&self) {
862        let mut state = self.state.lock();
863        if state.draining {
864            return;
865        }
866        state.notify_started = true;
867        self.apply(&state);
868    }
869
870    /// Puts the stream into post-close drain mode, silencing the SURB-level notification.
871    ///
872    /// Returns `false` if the stream was draining already, which is how two closes racing through
873    /// `begin_surb_drain` settle which of them owns the drain. The read and the write have to be one
874    /// lock acquisition for that: callers that each saw [`is_draining`](Self::is_draining) before
875    /// either wrote it would both start a drain, and both tell the supervisor about it.
876    ///
877    /// The notification is the single packet [`admit_at`](Self::admit_at) exempts from the SURB
878    /// reserve, and the exemption exists for one reason: it is the message that asks the Entry for
879    /// more SURBs. It asks on behalf of a Session that a drain has already lost — the Entry itself
880    /// may well still be there, but nothing is left to act on the request — so leaving it on would
881    /// originate below the reserve into a buffer nothing is refilling, which is the one failure mode
882    /// [`PixFillConfig::min_surb_reserve`](crate::supervision::PixFillConfig::min_surb_reserve)
883    /// exists to prevent, since a return packet with no SURB holds up every packet this node
884    /// originates. With it off, `notification_due` is always false and the reserve is the only gate.
885    pub(crate) fn enter_drain(&self) -> bool {
886        let mut state = self.state.lock();
887        if state.draining {
888            return false;
889        }
890        state.draining = true;
891        state.notify_started = false;
892        self.apply(&state);
893        true
894    }
895
896    /// Whether this Session is draining its SURB buffer after having been closed.
897    pub(crate) fn is_draining(&self) -> bool {
898        self.state.lock().draining
899    }
900
901    /// The SURBs this Session could still spend on a drain, net of the reserve it must leave behind.
902    ///
903    /// An estimate and an upper bound at that — ring-buffer overwrites, store eviction and
904    /// invalidated relayers are all unobserved — which is why the supervisor weighs it against a
905    /// cycle's whole remaining emission rather than a discounted one. The reserve subtracted is the
906    /// *effective* one already derived for this Session, not the configured ceiling.
907    pub(crate) fn drainable_surbs(&self) -> u64 {
908        self.estimator.saturating_diff().saturating_sub(self.min_surb_reserve)
909    }
910
911    /// Applies a rate planned by the PIX supervisor.
912    pub(crate) fn set_fill(&self, rate: FillRate) {
913        let mut state = self.state.lock();
914        if state.fill == rate {
915            return;
916        }
917        state.fill = rate;
918        self.apply(&state);
919    }
920
921    /// The rate the stream is currently running at — the faster of the two producers.
922    ///
923    /// The production path never asks: [`apply`](Self::apply) derives it and hands it straight to the
924    /// controller, which is the only consumer that matters. This exists so the merge rule can be
925    /// tested as the rule it is, rather than inferred from packet timings.
926    #[cfg(test)]
927    pub(crate) fn effective_rate(&self) -> FillRate {
928        Self::effective(&self.state.lock(), self.notify_period)
929    }
930
931    fn effective(state: &PixFillState, notify_period: Option<Duration>) -> FillRate {
932        let notify = state
933            .notify_started
934            .then_some(notify_period)
935            .flatten()
936            .map(FillRate::once_per);
937        let fill = (!state.fill.is_zero()).then_some(state.fill);
938        match (notify, fill) {
939            (Some(notify), Some(fill)) if fill.as_packets_per_sec() > notify.as_packets_per_sec() => fill,
940            (Some(notify), _) => notify,
941            (None, Some(fill)) => fill,
942            (None, None) => FillRate::ZERO,
943        }
944    }
945
946    fn apply(&self, state: &PixFillState) {
947        let rate = Self::effective(state, self.notify_period);
948        // A zero period would trip the controller's own assertion. `FillRate` never carries one —
949        // `ZERO` is one second and the two constructors take a validated period — so this is a
950        // belt-and-braces floor rather than a case that arises.
951        self.controller
952            .set_rate_per_unit(rate.packets as usize, rate.per.max(Duration::from_micros(1)));
953        trace!(
954            session_id = %self.session_id,
955            packets = rate.packets,
956            per = ?rate.per,
957            "applied the exit keep-alive rate"
958        );
959    }
960
961    /// Decides whether the next keep-alive may go out, and books it if so.
962    ///
963    /// The SURB-level notification always may: it is the message that asks the Entry for more SURBs,
964    /// so refusing it when SURBs are scarce is refusing to ask for the thing that is scarce.
965    ///
966    /// Fill above that rate is refused while the estimate is below the reserve, and that refusal is
967    /// not an optimisation. A return packet that finds no SURB is not dropped — the routing resolver
968    /// holds it, and with it every packet this node originates, for the whole of
969    /// `surb_resolution_wait`. An Exit filling a cycle down to its last SURB would therefore stall
970    /// the Session it is filling for, and every other Session on the node with it.
971    ///
972    /// # Which packet is the notification
973    ///
974    /// Nothing about a packet says which producer it belongs to, so the notification is the first one
975    /// released no sooner than a period after the last — *less a slack of
976    /// `min(MAX_WAIT_CHUNK, period / 2)`*, which is what keeps the classification from resting on
977    /// scheduling latency. The rate limiter that releases these packets sleeps in chunks of
978    /// [`MAX_WAIT_CHUNK`], re-reads its controller on each wake, and rounds a rate into an integer
979    /// packet count, so a stream asked for one packet per period releases them a shade over or a
980    /// shade under it. Compared against the period exactly, a period the controller rounds short by a
981    /// microsecond would classify every other notification as fill — counted in
982    /// `hopr_session_pix_fill_packets_total` and, below the reserve, dropped. That is the signal
983    /// asking the Entry for the SURBs whose absence caused the drop, and it runs on every Exit
984    /// Session with a `surb_balance_notify_period` configured, PIX or not.
985    ///
986    /// The slack cannot let fill masquerade as the notification: `last_notify_at` advances to the
987    /// instant the packet actually went out, so only the *first* packet past the slack window is the
988    /// notification, and every fill packet that follows it inside the period is still fill and is
989    /// still bounded by the reserve. The cap at half the period is what stops a very short period
990    /// from having its window widened away to nothing.
991    pub(crate) fn admit(&self) -> bool {
992        self.admit_at(Instant::now())
993    }
994
995    /// [`admit`](Self::admit) against a caller-supplied instant, so the classification can be tested
996    /// as the rule it is rather than by sleeping out a period and hoping the scheduler cooperates.
997    fn admit_at(&self, now: Instant) -> bool {
998        let mut state = self.state.lock();
999
1000        // Due *slightly early*: see the classification rule on `admit`. The slack is what keeps this
1001        // from resting on the scheduling latency of the stream that releases the packet.
1002        let notification_due = state.notify_started
1003            && self.notify_period.is_some_and(|period| {
1004                let slack = MAX_WAIT_CHUNK.min(period / 2);
1005                state
1006                    .last_notify_at
1007                    .is_none_or(|at| now.saturating_duration_since(at) >= period.saturating_sub(slack))
1008            });
1009
1010        if notification_due {
1011            state.last_notify_at = Some(now);
1012            return true;
1013        }
1014
1015        if self.estimator.saturating_diff() >= self.min_surb_reserve {
1016            Self::set_withholding(&mut state, false);
1017            #[cfg(feature = "telemetry")]
1018            telemetry::record_pix_fill_packet();
1019            return true;
1020        }
1021
1022        debug!(
1023            session_id = %self.session_id,
1024            reserve = self.min_surb_reserve,
1025            estimate = self.estimator.saturating_diff(),
1026            "withholding a PIX fill keep-alive to stay above the SURB reserve"
1027        );
1028        Self::set_withholding(&mut state, true);
1029        #[cfg(feature = "telemetry")]
1030        telemetry::pix::record_pix_fill_backoff(telemetry::pix::PixFillBackoff::SurbReserve);
1031        false
1032    }
1033
1034    /// Moves the `surb_starved` gauge on the edges only, so it counts stalled Sessions rather than
1035    /// refused packets — the same distinction `PixGateBlockEpisode` draws for the egress gate.
1036    fn set_withholding(state: &mut PixFillState, withholding: bool) {
1037        if state.withholding == withholding {
1038            return;
1039        }
1040        state.withholding = withholding;
1041        crate::supervision::telemetry::emit_sessions_stalled(
1042            crate::supervision::telemetry::PixStallCause::SurbStarved,
1043            if withholding { 1 } else { -1 },
1044        );
1045    }
1046}
1047
1048/// Returns this Session's `surb_starved` count if it was withholding when it went away.
1049///
1050/// `Drop` rather than a call on the close paths, for the reason `PixGateBlockEpisode` gives: a
1051/// Session can end by teardown, by drain, or by its last `Arc` going out of scope in a task nobody
1052/// is waiting on, and this is the one mechanism that covers all three. Without it a Session that
1053/// dies mid-stall leaves the gauge standing for the life of the process.
1054impl Drop for PixFillControl {
1055    fn drop(&mut self) {
1056        Self::set_withholding(self.state.get_mut(), false);
1057    }
1058}
1059
1060/// The SURB reserve one Session's fill actually respects.
1061///
1062/// [`PixFillConfig::min_surb_reserve`](crate::supervision::PixFillConfig::min_surb_reserve) is a
1063/// *ceiling* rather than the value used, and it has to be, because the two sides of the comparison
1064/// are chosen by different nodes. The reserve is measured against the Exit's estimate of its own SURB
1065/// buffer for the Session, and how deep that buffer is is the *Entry's* decision — it announces its
1066/// balancer target in the lower 32 bits of the options word in `StartInitiation::additional_data`. An operator sizing
1067/// the reserve against a production buffer therefore also decides, unintentionally, that every Session
1068/// whose Entry asks for a shallower one can never be filled at all: the estimate never reaches the
1069/// reserve, every fill packet is withheld, and the funded cycle strands on `max_recovery_time` while
1070/// the logs report only a backoff.
1071///
1072/// A quarter of the announced target is what the reserve is for expressed as a proportion: it leaves
1073/// fill three quarters of whatever buffer the Entry chose and keeps the last quarter for the
1074/// application, which is the side that has something waiting on it. The configured value still binds
1075/// wherever it is the smaller of the two — at the shipped 500 against the worked profile's 7 000-SURB
1076/// target, a quarter is 1 750, so the derivation is inert on a production-sized Session and only
1077/// engages on one whose Entry asked for less than four times the configured reserve.
1078///
1079/// Floored at one, for the same reason the constructor clamps: a zero reserve is fill spending the
1080/// Session's last SURB, and an Entry announcing a three-SURB buffer would otherwise produce exactly
1081/// that.
1082fn effective_surb_reserve(configured: u64, announced_target: u64) -> u64 {
1083    configured.min(announced_target / 4).max(1)
1084}
1085
1086/// One Session's reservation against the node's live reconstructor-cycle budget.
1087///
1088/// Charged when a PIX Session is accepted and returned when its Session closes. Deliberately a
1089/// projection rather than a measurement: the alternative is to weigh the reconstructor's actual
1090/// state and refuse a share once it is too large, which loses the cycle of whichever Session
1091/// happened to arrive last rather than of the one that inflated it. A reservation taken up front can
1092/// only ever refuse a Session that does not exist yet.
1093#[derive(Debug)]
1094pub(crate) struct CycleBudgetReservation {
1095    bytes: u64,
1096    outstanding: Arc<std::sync::atomic::AtomicU64>,
1097    /// Whether [`release`](Self::release) has already run, so it can be called from both the close
1098    /// path and `Drop` without the budget being returned twice.
1099    released: std::sync::atomic::AtomicBool,
1100}
1101
1102impl CycleBudgetReservation {
1103    /// Returns the reservation to the node's budget. Idempotent.
1104    ///
1105    /// Called explicitly by [`close_session`], and by `Drop` as the backstop.
1106    ///
1107    /// Both, and not just `Drop`, because the slot lives in a `moka` cache: `remove` hands the value
1108    /// back but drops the cache's own clone during a later maintenance pass, so a purely
1109    /// refcount-driven release would return the budget at an unpredictable time — and a node whose
1110    /// Sessions all closed could still refuse the next one. The explicit call makes the release
1111    /// simultaneous with the closure that caused it; the flag is what keeps the deferred drop from
1112    /// crediting it a second time.
1113    fn release(&self) {
1114        if self.released.swap(true, Ordering::Relaxed) {
1115            return;
1116        }
1117        // Saturating, so a release the flag somehow failed to suppress could not wrap the counter
1118        // into a budget that admits everything.
1119        let outstanding = self
1120            .outstanding
1121            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |held| {
1122                Some(held.saturating_sub(self.bytes))
1123            })
1124            .unwrap_or_default()
1125            .saturating_sub(self.bytes);
1126
1127        #[cfg(feature = "telemetry")]
1128        crate::telemetry::pix::record_cycle_bytes_released(self.bytes);
1129
1130        trace!(
1131            released = self.bytes,
1132            outstanding, "released live-cycle budget reservation"
1133        );
1134    }
1135}
1136
1137impl Drop for CycleBudgetReservation {
1138    fn drop(&mut self) {
1139        self.release();
1140    }
1141}
1142
1143/// RAII guard that rolls back a freshly inserted [`SessionSlot`] unless the
1144/// session setup is explicitly [committed](SessionSlotGuard::commit).
1145///
1146/// Establishing a session involves several fallible steps *after* the slot has
1147/// been inserted into the Session cache (constructing the [`HoprSession`],
1148/// notifying about the new session, sending the establishment message, ...).
1149/// If any of these steps fails, the already inserted slot would otherwise linger
1150/// in the cache until idle eviction, blocking the pseudonym (and counting towards
1151/// `maximum_sessions`) in the meantime.
1152///
1153/// Dropping this guard without committing removes the slot and tears down the
1154/// partially initialized session. Since Moka's removal is asynchronous and Rust
1155/// has no asynchronous `Drop`, the cleanup is performed on a spawned task.
1156struct SessionSlotGuard<'a> {
1157    sessions: &'a moka::sync::Cache<SessionId, SessionSlot>,
1158    active_sessions: Arc<std::sync::atomic::AtomicUsize>,
1159    session_id: SessionId,
1160    committed: bool,
1161}
1162
1163impl<'a> SessionSlotGuard<'a> {
1164    fn new(
1165        sessions: &'a moka::sync::Cache<SessionId, SessionSlot>,
1166        session_id: SessionId,
1167        active_sessions: Arc<std::sync::atomic::AtomicUsize>,
1168    ) -> Self {
1169        Self {
1170            sessions,
1171            active_sessions,
1172            session_id,
1173            committed: false,
1174        }
1175    }
1176
1177    /// Marks the session as successfully established, preventing the slot from
1178    /// being rolled back when this guard is dropped.
1179    ///
1180    /// Logs "new session established" here, on both the Entry and the Exit side, so that every
1181    /// "session closed" line from [`close_session`] has a matching start line.
1182    fn commit(&mut self) {
1183        self.committed = true;
1184        tracing::info!(session_id = %self.session_id, "new session established");
1185
1186        #[cfg(all(feature = "telemetry", not(test)))]
1187        METRIC_ACTIVE_SESSIONS.increment(1.0);
1188    }
1189}
1190
1191impl Drop for SessionSlotGuard<'_> {
1192    fn drop(&mut self) {
1193        if !self.committed {
1194            // The session setup failed after the slot was inserted: remove it so it does
1195            // not block the pseudonym until idle eviction.
1196            let session_id = self.session_id;
1197            warn!(%session_id, "rolling back partially established session slot after setup failure");
1198            if let Some(slot) = self.sessions.remove(&session_id) {
1199                self.active_sessions.fetch_sub(1, Ordering::Relaxed);
1200                // Not `close_session`: the Session was never established, so there is no
1201                // "session closed" line to pair with "new session established".
1202                release_session_slot(session_id, slot, ClosureReason::Eviction);
1203            }
1204        }
1205    }
1206}
1207
1208/// Why an inbound session data packet was dropped without being delivered.
1209///
1210/// All three are ordinary consequences of a session tearing down or being briefly overwhelmed, not
1211/// faults: a departing counterparty leaves packets in flight that arrive after its slot's sink is
1212/// gone or after the slot itself is deregistered. They are reported as a [`DispatchResult::Dropped`]
1213/// outcome rather than an `Err` precisely so the caller does not log them at `ERROR` — the loud
1214/// teardown-race spam this distinction exists to remove.
1215#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1216pub enum DropReason {
1217    /// The slot is still registered but its data sink (receiver) has been dropped
1218    /// (`TrySendError::Disconnected`). The common case when a session is being torn down.
1219    SinkClosed,
1220    /// The slot's inbox is full (`TrySendError::Full`): genuine backpressure, the reader is not
1221    /// keeping up. Distinct from [`SinkClosed`](Self::SinkClosed) because it is transient, not a
1222    /// teardown.
1223    SinkFull,
1224    /// No slot for this session id is registered (formerly the `UnknownData` error): the session
1225    /// was fully deregistered before this packet arrived.
1226    Unregistered,
1227}
1228
1229/// Indicates the result of processing a message.
1230#[derive(Clone, Debug, PartialEq, Eq)]
1231pub enum DispatchResult {
1232    /// Session or Start protocol message has been processed successfully.
1233    Processed,
1234    /// The message was not related to Start or Session protocol.
1235    Unrelated(ApplicationDataIn),
1236    /// The message belonged to a session but could not be delivered and was dropped. Benign — see
1237    /// [`DropReason`]. Reported as `Ok` so the caller logs it quietly instead of as an `ERROR`.
1238    Dropped(DropReason),
1239}
1240
1241/// Configuration of the PIX protocol for incoming Sessions on Exit nodes.
1242#[derive(Clone, Debug, PartialEq, smart_default::SmartDefault, serde::Serialize, serde::Deserialize)]
1243#[serde(default, deny_unknown_fields)]
1244pub struct IncomingSessionPixConfig {
1245    /// If set to true, incoming Session without the [`Capability::UsePIX`] will be rejected.
1246    ///
1247    /// Default `false`.
1248    #[default(false)]
1249    pub enforce_pix: bool,
1250    /// Acceptable range of data quota in bytes.
1251    ///
1252    /// With dynamic SSA batches enabled, the Exit selects the smallest batch up to
1253    /// [`SupervisorConfig::ssas_per_request`] whose total `batch_size × quota_per_ssa` is inside this
1254    /// range. With dynamic batches disabled, the Entry's per-SSA quota itself must be inside the
1255    /// range and the configured batch size is used exactly. An offer that satisfies neither rule is
1256    /// rejected before the Session is established.
1257    ///
1258    /// The default is derived from the default PIX dimensions
1259    /// ([`crate::DEFAULT_PIX_POLYS_PER_SSA`] × ([`crate::DEFAULT_PIX_SHARES_PER_POLY`] +
1260    /// [`crate::DEFAULT_PIX_SURPLUS_SHARES`])) rather than hard-coded, so that an Entry running the
1261    /// default configuration is always accepted. The upper bound is exactly
1262    /// [`DEFAULT_PIX_SSA_QUOTA`]. Since every dynamic batch has at least one member, that upper bound
1263    /// still caps the data served by any one SSA cycle. Accepting larger dimensions would raise both
1264    /// that exposure and the reconstructor memory held per Session, so an Exit that wants to serve
1265    /// them must widen this range explicitly.
1266    ///
1267    /// The quota it is compared against counts the surplus — `polys × (threshold + surplus) ×
1268    /// PIX_QUOTA_BYTES_PER_SHARE` — so this bounds the traffic actually served rather than the
1269    /// fraction of it the threshold accounts for. It used to bound only the latter, which understated
1270    /// the exposure by the surplus factor: 3.47× at the deployed dimensions.
1271    ///
1272    /// Defaults to `DEFAULT_PIX_SSA_QUOTA / 4 ..= DEFAULT_PIX_SSA_QUOTA`
1273    /// (≈ 157 MiB to ≈ 630 MiB, inclusive).
1274    #[default(_code = "DEFAULT_PIX_SSA_QUOTA / DEFAULT_PIX_QUOTA_RANGE_SPAN..=DEFAULT_PIX_SSA_QUOTA")]
1275    pub quota_range: std::ops::RangeInclusive<u64>,
1276    /// Ceiling on the live Exit-side reconstructor state this node will commit to, in bytes.
1277    ///
1278    /// **This, not [`SessionManagerConfig::maximum_sessions`], is what bounds reconstructor
1279    /// memory.** A PIX Session reserves `MAX_OVERLAPPING_BATCHES × ssas_per_request ×
1280    /// hopr_protocol_pix::peak_cycle_bytes(offered params)` when it is accepted and holds it until
1281    /// it closes; a Session that does not fit is refused with
1282    /// [`StartErrorReason::NoSlotsAvailable`] before any state is allocated for it. The reservation
1283    /// is computed from the parameters the *peer* offered, so a Session asking for smaller
1284    /// dimensions costs proportionally less of the budget.
1285    ///
1286    /// Enforced at admission rather than validated as a product of the configuration, for the reason
1287    /// `SsaReconstructorConfig::max_ack_buffer_bytes` gives: `maximum_sessions` validates to
1288    /// 100 000, and the resulting product is a number no node could hold, so validating it would
1289    /// only ever reject the shipping defaults. Counting what has actually been committed to is
1290    /// indifferent to how the ceiling was configured.
1291    ///
1292    /// Default is 3 GiB. At the default dimensions a Session reserves ≈30.7 MiB — two overlapping
1293    /// batches of one cycle's peak each — so this admits 100 concurrent PIX Sessions with ≈1.7 MiB to
1294    /// spare, comfortably covering the 10–30 clients per Exit the calibration profile models. That
1295    /// is exactly the 100 Sessions a node manages by default (`maximum_managed_sessions`), which at
1296    /// these dimensions reserve ≈2.998 GiB — so with every default in place the two limits meet and
1297    /// this budget is what binds as soon as peers offer a costlier split of the same quota or that
1298    /// limit is raised. The margin is pinned by
1299    /// `default_live_cycle_budget_admits_the_default_managed_session_count`.
1300    ///
1301    /// A ceiling on *reservations*, not an allocation: nothing is claimed up front, and because the
1302    /// reservation is denominated at the adversarial peak, a node serving conforming peers holds an
1303    /// order of magnitude less than it has reserved. The number exists to stop an Exit selling more
1304    /// service than its memory can hold, not to describe what it will typically use.
1305    #[default(3 * 1024 * 1024 * 1024)]
1306    pub max_live_cycle_bytes: u64,
1307    /// Deadlines, fault limits and service budget the Exit-side PIX supervisor enforces on a
1308    /// Session.
1309    ///
1310    /// These live together rather than spread across this struct because they are only meaningful
1311    /// as a set: [`crate::validate_pix_supervision`] checks them against each other and against the
1312    /// reconstructor's lifetimes, and the node's config validator runs that at load time.
1313    pub supervision: SupervisorConfig,
1314}
1315
1316impl IncomingSessionPixConfig {
1317    /// The terms one Session is judged on, once the session server has had its say.
1318    ///
1319    /// The configured [`quota_range`](Self::quota_range) is an envelope, not a default: it is what
1320    /// [`validate_incoming_session_pix_config`] checked the deadlines and memory ceiling against,
1321    /// and what [`start_protocol_channel_capacity`] sized a preallocated ring from, both once at
1322    /// startup. So a decision's range is *intersected* with it rather than replacing it — a Session
1323    /// may be held to less than the node advertises, never to more than it was configured to
1324    /// honour. An operator wanting a wider class configures a wider envelope and narrows the rest.
1325    ///
1326    /// [`enforce_pix`](Self::enforce_pix) carries no such coupling and is simply overridden.
1327    fn effective_for(&self, decision: &SessionAdmissionDecision) -> EffectivePixAdmission {
1328        let quota_range = match &decision.pix_quota_range {
1329            Some(narrowed) => {
1330                *self.quota_range.start().max(narrowed.start())..=*self.quota_range.end().min(narrowed.end())
1331            }
1332            None => self.quota_range.clone(),
1333        };
1334
1335        EffectivePixAdmission {
1336            enforce_pix: decision.enforce_pix.unwrap_or(self.enforce_pix),
1337            quota_range,
1338            allow_dynamic_ssa_batches: self.supervision.allow_dynamic_ssa_batches,
1339            ssas_per_request: self.supervision.ssas_per_request,
1340        }
1341    }
1342
1343    /// The supervisor configuration for Sessions accepted under these settings.
1344    pub fn supervisor_config(&self) -> SupervisorConfig {
1345        self.supervision.clone()
1346    }
1347}
1348
1349/// The PIX terms a single incoming Session is admitted on.
1350///
1351/// Lighter than the whole [`IncomingSessionPixConfig`] because only these four fields vary per
1352/// Session: everything else the supervisor enforces is validated as a set at startup and stays
1353/// node-wide. Narrowing the quota only makes those deadlines more generous, which is why they need
1354/// not travel with it.
1355#[derive(Clone, Debug, PartialEq)]
1356pub(crate) struct EffectivePixAdmission {
1357    /// Whether this Session must offer PIX at all.
1358    enforce_pix: bool,
1359    /// Quota window this Session's offer must land in, already intersected with the node's envelope.
1360    quota_range: std::ops::RangeInclusive<SsaQuota>,
1361    /// Whether a batch may be derived from the offer, rather than taken as configured.
1362    allow_dynamic_ssa_batches: bool,
1363    /// Largest batch the Exit will ask for.
1364    ssas_per_request: usize,
1365}
1366
1367impl EffectivePixAdmission {
1368    /// Selects the batch size with which an Entry's per-SSA quota is acceptable.
1369    ///
1370    /// Dynamic mode walks from one upwards so it asks for no more Entry work, deposits or Exit
1371    /// reconstructor state than necessary. Fixed mode is the legacy rule: validate one SSA against
1372    /// the range, then request exactly the configured batch size.
1373    fn ssa_batch_size_for_quota(&self, quota_per_ssa: SsaQuota) -> Option<usize> {
1374        let configured_batch = self.ssas_per_request.clamp(1, MAX_SSA_BATCH_SIZE);
1375
1376        if !self.allow_dynamic_ssa_batches {
1377            return self.quota_range.contains(&quota_per_ssa).then_some(configured_batch);
1378        }
1379
1380        (1..=configured_batch).find(|batch_size| {
1381            quota_per_ssa
1382                .checked_mul(*batch_size as u64)
1383                .is_some_and(|batch_quota| self.quota_range.contains(&batch_quota))
1384        })
1385    }
1386}
1387
1388/// Bit position of the 64-bit options word within `StartInitiation::additional_data`.
1389///
1390/// The options word (PIX parameters and SURB balancer target) occupies the most significant 64 bits;
1391/// the least significant 64 bits are currently unused and always sent as `0`.
1392const INITIATION_OPTIONS_SHIFT: u32 = u64::BITS;
1393
1394/// Places the 64-bit options word into the most significant half of `StartInitiation::additional_data`,
1395/// leaving the (currently unused) least significant half zero.
1396#[inline]
1397const fn into_initiation_additional_data(options: u64) -> u128 {
1398    (options as u128) << INITIATION_OPTIONS_SHIFT
1399}
1400
1401/// Inverse of [`into_initiation_additional_data`], ignoring the least significant half.
1402#[inline]
1403const fn initiation_options(additional_data: u128) -> u64 {
1404    (additional_data >> INITIATION_OPTIONS_SHIFT) as u64
1405}
1406
1407/// Decodes the incentivization the Entry offered, before anyone is asked what to do about it.
1408///
1409/// `Ok(None)` is a peer that offered none. `Err` is one whose offer this node cannot read or
1410/// cannot share a curve with, which is refused on its own — no policy could rescue it, and both
1411/// questions are cheaper than the round trip to the session server that follows.
1412///
1413/// Split out from `SessionManager::check_pix_params` so the session server can be
1414/// told what was offered: it decides the terms, and terms relative to an offer need the offer.
1415fn decode_pix_offer(
1416    req: &StartInitiation<SessionTarget, HoprSessionCapabilities>,
1417) -> Result<Option<PixParams>, StartErrorReason> {
1418    if !req.capabilities.0.contains(Capability::UsePIX) {
1419        return Ok(None);
1420    }
1421
1422    // Unpacking is what enforces the protocol ranges on the three dimensions, and what rejects a
1423    // suite identifier no curve claims — leaving only "a known curve, but not ours" below.
1424    let params = PixParams::try_from_additional_data(initiation_options(req.additional_data)).map_err(|error| {
1425        debug!(
1426            challenge = req.challenge,
1427            %error,
1428            "client offered PIX parameters outside the protocol ranges"
1429        );
1430        StartErrorReason::UnacceptablePixParams
1431    })?;
1432
1433    // The Exit decides the curve, and it decides it by refusing anything else: nothing here
1434    // is negotiated, because there is nothing to negotiate — the suite is fixed at build
1435    // time on both sides. Checked before the quota because it is the cheaper question and
1436    // because a suite mismatch makes the dimensions meaningless anyway, and checked at all
1437    // because every later PIX field is sized by it. Refusing here means the Exit's own
1438    // commitments, the first curve-sized bytes in the exchange, are never sent to a peer
1439    // that would read their boundaries in the wrong place.
1440    if params.suite() != LOCAL_PIX_SUITE {
1441        warn!(
1442            challenge = req.challenge,
1443            offered = %params.suite(),
1444            ours = %LOCAL_PIX_SUITE,
1445            "refusing a client offering a PIX curve suite this node was not built for"
1446        );
1447        return Err(StartErrorReason::UnacceptablePixParams);
1448    }
1449
1450    Ok(Some(params))
1451}
1452
1453/// Validates the incoming-PIX invariants that span quota, supervision and memory settings.
1454///
1455/// `max_session_packet_rate` is the fastest a Session is assumed to go. Without fill it bounds
1456/// `max_recovery_time` directly; with fill it caps the `fill.max_rate` the deadline may count on.
1457pub fn validate_incoming_session_pix_config(
1458    cfg: &IncomingSessionPixConfig,
1459    max_session_packet_rate: u64,
1460) -> errors::Result<()> {
1461    if cfg.quota_range.is_empty() {
1462        return Err(TransportSessionError::InvalidConfig(
1463            "PIX quota_range must be non-empty (start must not exceed end)".into(),
1464        ));
1465    }
1466    if max_session_packet_rate == 0 {
1467        return Err(TransportSessionError::InvalidConfig(
1468            "maximum assumed PIX Session packet rate must be non-zero".into(),
1469        ));
1470    }
1471    // Named here although `validate_pix_supervision` also refuses it, because that runs later on both
1472    // paths: with fill enabled a zero deadline would otherwise surface as an infinite fill rate.
1473    if cfg.supervision.max_recovery_time.is_zero() {
1474        return Err(TransportSessionError::InvalidConfig(
1475            "PIX max_recovery_time must be non-zero".into(),
1476        ));
1477    }
1478
1479    let worst_cycle_packets = *cfg.quota_range.end() / PIX_QUOTA_BYTES_PER_SHARE;
1480
1481    // `max_recovery_time` has to leave a cycle of the widest accepted quota time to complete, and what
1482    // completes it depends on fill. With fill enabled the Exit finishes an under-served cycle itself, so
1483    // the bound is the fill ceiling's own arithmetic below. Without it only application traffic does,
1484    // and the Exit has no rate of its own to reason from.
1485    if cfg.supervision.fill.enabled {
1486        // The fill ceiling has to clear the rate a cycle of the widest accepted quota actually needs,
1487        // or the mechanism that exists to complete idle cycles cannot complete the very ones it
1488        // admits. The arithmetic is the planner's own, taken at the moment fill starts: the whole
1489        // cycle remains, the aim point sits `finish_fraction` of the way through `max_recovery_time`,
1490        // and the loss margin is paid on top. `max_recovery_time` is the *configured* per-cycle budget
1491        // rather than a deadline observed at run time, so this is a property of the configuration and
1492        // can be decided here.
1493        //
1494        // The ceiling counts only up to `max_session_packet_rate`: fill faster than any Session is
1495        // assumed to carry cannot finish a cycle any sooner.
1496        let fill = &cfg.supervision.fill;
1497        // Before the arithmetic, not after it. `validate_pix_supervision` also range-checks these two,
1498        // but it runs later on both paths that reach here — `SessionManager::start` calls this first,
1499        // and in `HoprProtocolConfig` this is a field validator while that is a schema-level one. The
1500        // `mul_f64` below panics on a `NaN` or negative fraction, so an unchecked one aborts the node
1501        // at config load rather than being reported; a fraction above one silently lowers the floor.
1502        crate::supervision::validate_fill_fractions(fill)?;
1503
1504        let horizon = cfg.supervision.max_recovery_time.mul_f64(fill.finish_fraction);
1505        // Still guarded although both factors are non-zero: a nanosecond-scale deadline times a
1506        // fraction rounds to zero, and dividing by it would report an infinite requirement.
1507        let floor = if horizon.is_zero() {
1508            u64::MAX
1509        } else {
1510            (worst_cycle_packets as f64 * (1.0 + fill.loss_margin) / horizon.as_secs_f64()).ceil() as u64
1511        };
1512        if (fill.max_rate as u64).min(max_session_packet_rate) < floor {
1513            let remedy = if floor > max_session_packet_rate {
1514                "raise max_recovery_time"
1515            } else {
1516                "raise fill.max_rate or max_recovery_time"
1517            };
1518            return Err(TransportSessionError::InvalidConfig(format!(
1519                "PIX fill.max_rate is {} (counted up to {max_session_packet_rate} packets/s), but the largest \
1520                 accepted quota ({} bytes) is {worst_cycle_packets} packets and needs at least {floor} packets/s to \
1521                 finish inside {horizon:?} with a {} loss margin; {remedy}",
1522                fill.max_rate,
1523                cfg.quota_range.end(),
1524                fill.loss_margin,
1525            )));
1526        }
1527    } else {
1528        // Refused only if even a Session at the fastest assumed rate could not finish a cycle in time,
1529        // so this rejects a deadline that is impossible rather than one that is merely tight.
1530        let needed = Duration::from_secs(worst_cycle_packets.div_ceil(max_session_packet_rate));
1531        if cfg.supervision.max_recovery_time < needed {
1532            return Err(TransportSessionError::InvalidConfig(format!(
1533                "PIX max_recovery_time is {:?}, but with fill disabled the largest accepted quota ({} bytes) is \
1534                 {worst_cycle_packets} packets and needs at least {needed:?} even at {max_session_packet_rate} \
1535                 packets/s",
1536                cfg.supervision.max_recovery_time,
1537                cfg.quota_range.end(),
1538            )));
1539        }
1540    }
1541
1542    let widest = max_cycle_budget_for_quota(*cfg.quota_range.end(), cfg.supervision.ssas_per_request);
1543    if cfg.max_live_cycle_bytes < widest {
1544        return Err(TransportSessionError::InvalidConfig(format!(
1545            "PIX max_live_cycle_bytes is {}, but one Session offering the largest accepted quota ({} bytes) reserves \
1546             up to {widest} bytes of reconstructor state",
1547            cfg.max_live_cycle_bytes,
1548            cfg.quota_range.end(),
1549        )));
1550    }
1551
1552    Ok(())
1553}
1554
1555/// Configuration for the [`SessionManager`].
1556#[derive(Clone, Debug, PartialEq, smart_default::SmartDefault)]
1557pub struct SessionManagerConfig {
1558    /// The maximum chunk of data that can be written to the Session's input buffer.
1559    ///
1560    /// Clamped to the socket's supported frame size range, preserving boundaries within that range.
1561    ///
1562    /// Default is [`SESSION_MTU`], i.e. exactly one segment per frame.
1563    #[default(SESSION_MTU)]
1564    pub frame_mtu: usize,
1565
1566    /// The maximum time for an incomplete frame to stay in the Session's output buffer.
1567    ///
1568    /// Must exceed the worst-case SURB replenishment latency on the return path.
1569    /// The SURB balancer replenishes via KeepAlive every ~2 s; the retry loop in
1570    /// the routing resolver sleeps 5 ms per attempt and can block for the full
1571    /// replenishment cycle.  Setting this below the KeepAlive period causes the
1572    /// sequencer to discard frames that arrive after a SURB-starved peer finally
1573    /// gets new SURBs and sends its echo.
1574    ///
1575    /// Default is 3 s.
1576    #[default(Duration::from_secs(3))]
1577    pub max_frame_timeout: Duration,
1578
1579    /// Maximum number of segments to buffer in the downstream transport of a Session's socket.
1580    /// If 0 is given, the transport is unbuffered.
1581    ///
1582    /// Default is 0.
1583    #[default(0)]
1584    pub max_buffered_segments: usize,
1585
1586    /// Abandon the frame due next once this many later frames are waiting behind it, rather than
1587    /// holding them for the whole of [`Self::max_frame_timeout`].
1588    ///
1589    /// The two answer different questions. `max_frame_timeout` is how long a *missing* frame is
1590    /// waited for, and is set here to exceed the ~2 s SURB KeepAlive so a starved peer's late echo
1591    /// is not discarded. This bounds how much *already received* data is held hostage during that
1592    /// wait -- a cost paid once per gap, which compounds as later frames queue up.
1593    ///
1594    /// Without it, a session that cannot retransmit waits the full timeout for a frame that will
1595    /// never arrive. Measured on a 5-node cluster after killing a return relayer: 98.5 % of bytes
1596    /// returned over the wire, 0.60 % reached the application, and the application-side
1597    /// inter-arrival median sat exactly on the 3 s timeout.
1598    ///
1599    /// The right value tracks reordering depth -- throughput x latency spread / frame size -- so
1600    /// it is deployment-specific. Tune with `SessionConfig::max_frames_behind_gap`; too low converts
1601    /// ordinary reordering into loss, too high leaves the stall in place.
1602    ///
1603    /// Default is 256, which is roughly 3-4x the reordering depth of the cluster it was measured
1604    /// on (~0.5 MB/s over paths spread across 10-260 ms, 1500 B frames, so ~70-85 frames in
1605    /// flight out of order). The margin is not cosmetic: at 64 -- about one reordering depth --
1606    /// a *healthy* baseline dropped from 100 % to 92.2 % arrival, because ordinary reordering was
1607    /// being read as loss. At 256 the same baseline returned 100 % while a return relayer killed
1608    /// mid-session still recovered 97.1 %, against 0.60 % with the bound absent.
1609    ///
1610    /// Default is 256.
1611    #[default(Some(256))]
1612    pub max_frames_behind_gap: Option<usize>,
1613
1614    /// The base timeout for initiation of Session initiation.
1615    ///
1616    /// The actual timeout is adjusted according to the number of hops for that Session:
1617    /// `t = initiation_time_out_base * (num_forward_hops + num_return_hops + 2)`
1618    ///
1619    /// Default is 500 milliseconds.
1620    #[default(Duration::from_millis(500))]
1621    pub initiation_timeout_base: Duration,
1622
1623    /// Timeout for Session to be closed due to inactivity.
1624    ///
1625    /// Default is 180 seconds.
1626    #[default(Duration::from_secs(180))]
1627    pub idle_timeout: Duration,
1628
1629    /// Minimum interval at which an establishing Session's cache slot is refreshed
1630    /// ("touched") to keep [`idle_timeout`](Self::idle_timeout) from evicting it while
1631    /// SURBs pre-load.
1632    ///
1633    /// The touch period is [`idle_timeout`](Self::idle_timeout)` / 2`, floored to this
1634    /// value so it never drops below a sane minimum for very short idle timeouts.
1635    ///
1636    /// Default is 100 milliseconds.
1637    #[default(Duration::from_millis(100))]
1638    pub min_session_touch_period: Duration,
1639
1640    /// The sampling interval for SURB balancer.
1641    /// It will make SURB control decisions regularly at this interval.
1642    ///
1643    /// Default is 100 milliseconds.
1644    #[default(Duration::from_millis(100))]
1645    pub balancer_sampling_interval: Duration,
1646
1647    /// Initial packets per second egress rate on an incoming Session.
1648    ///
1649    /// This only applies to incoming Sessions without the [`Capability::NoRateControl`] flag set.
1650    ///
1651    /// Default is 10 packets/second.
1652    #[default(10)]
1653    pub initial_return_session_egress_rate: usize,
1654
1655    /// Minimum period of time for which a SURB buffer at the Exit must
1656    /// endure if no SURBs are being received.
1657    ///
1658    /// In other words, it is the minimum period of time an Exit must withstand when
1659    /// no SURBs are received from the Entry at all. To do so, the egress traffic
1660    /// will be shaped accordingly to meet this requirement.
1661    ///
1662    /// This only applies to incoming Sessions without the [`Capability::NoRateControl`] flag set.
1663    ///
1664    /// Default is 5 seconds, minimum is 1 second.
1665    #[default(Duration::from_secs(5))]
1666    pub minimum_surb_buffer_duration: Duration,
1667
1668    /// Indicates the maximum number of SURBs in the SURB buffer to be requested when creating a new Session.
1669    ///
1670    /// This value is theoretically capped by the size of the global transport SURB ring buffer,
1671    /// so values greater than that do not make sense. This value should be ideally set equal
1672    /// to the size of the global transport SURB RB.
1673    ///
1674    /// Default is 10 000 SURBs.
1675    #[default(10_000)]
1676    pub maximum_surb_buffer_size: usize,
1677
1678    /// If set, the Session recipient (Exit) will notify the Session initiator (Entry) about
1679    /// its SURB balance for the Session using keep-alive packets periodically.
1680    ///
1681    /// Keep in mind that each notification also costs 1 SURB, so the notification period should
1682    /// not be too frequent.
1683    ///
1684    /// Default is None (no notification sent to the client), minimum is 1 second.
1685    #[default(None)]
1686    pub surb_balance_notify_period: Option<Duration>,
1687
1688    /// If set, the Session initiator (Entry) will notify the Session recipient (Exit) about
1689    /// the local SURB balancer target using keep-alive packets from the SURB balancer.
1690    ///
1691    /// This is useful when the client plans to change the SURB balancer target dynamically.
1692    ///
1693    /// Default is true.
1694    #[default(true)]
1695    pub surb_target_notify: bool,
1696
1697    /// Maximum number of concurrent sessions allowed.
1698    ///
1699    /// Default is 10_000.
1700    #[default(10_000)]
1701    pub maximum_sessions: usize,
1702
1703    /// How many packets can be buffered if the [`HoprSession`] input socket is not fast enough.
1704    ///
1705    /// Controls the capacity of the internal `crossfire` channel used for each session slot.
1706    ///
1707    /// Default is 10 000.
1708    #[default(10000)]
1709    pub session_forward_capacity: usize,
1710
1711    /// Configuration of the PIX protocol for the Exit nodes.
1712    pub pix_config: IncomingSessionPixConfig,
1713
1714    /// Maximum number of SSA commitments this node, acting as an Entry, will accept in a single
1715    /// [`SsaServerCommitmentMessage`].
1716    ///
1717    /// This is the Entry's protection against a misbehaving Exit, not a preference: every accepted
1718    /// entry costs a full `new_ssa_commitment`, thousands of outbound `SsaCommit` packets and its own
1719    /// on-chain deposit, so without a cap one inbound packet amplifies into minutes of CPU, a large
1720    /// packet burst, and as many simultaneous deposits as the wire format admits. An over-cap request
1721    /// is rejected in full, before any commitment is generated or any `ReadyToDeposit` is emitted, and
1722    /// the Exit is told with a `SessionError` — see `refuse_ssa_request`.
1723    ///
1724    /// It must be at least the `ssas_per_request` of every Exit this node connects to — see
1725    /// [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request) for why a mismatch
1726    /// loses every Session.
1727    ///
1728    /// Clamped to `1..=`[`MAX_SSA_BATCH_SIZE`] in [`SessionManager::new`].
1729    ///
1730    /// Defaults to [`DEFAULT_MAX_SSAS_PER_SSA_REQUEST`] (2).
1731    #[default(DEFAULT_MAX_SSAS_PER_SSA_REQUEST)]
1732    pub max_ssas_per_ssa_request: usize,
1733
1734    /// How long the Exit waits for the session server to say whether a Session is admitted.
1735    ///
1736    /// The wait sits inside the peer's own initiation timeout, so it has to be comfortably shorter
1737    /// than [`initiation_timeout_base`](Self::initiation_timeout_base): a server slower than the
1738    /// peer is patient refuses every Session it is asked about, and does so after the peer has
1739    /// already given up. [`SessionManager::new`] rejects a configuration where it is not.
1740    ///
1741    /// Expiring refuses the Session — the alternative, admitting on the node's own terms, would let
1742    /// an overloaded or wedged server silently disable every rule the operator wrote.
1743    ///
1744    /// Default is 2 seconds.
1745    #[default(Duration::from_secs(2))]
1746    pub session_admission_timeout: Duration,
1747}
1748
1749// Type-erased sink used by the `SessionManager` to notify about newly incoming sessions.
1750// The errors produced by the underlying sink are remapped into `SessionManagerError`.
1751type BoxSink<T> = Pin<Box<dyn Sink<T, Error = SessionManagerError> + Send>>;
1752
1753type SessionNotifiers = (
1754    Arc<hopr_utils::runtime::prelude::Mutex<BoxSink<IncomingSession>>>,
1755    crossfire::MTx<crossfire::mpsc::Array<(SessionId, ClosureReason)>>,
1756);
1757
1758// Sink for processing Start protocol messages.
1759// Must be within Arc to be shared across SessionManager clones.
1760// The inner OnceLock is set once in `start()` and read in `dispatch_message`.
1761type StartProtocolMsgSink = Arc<OnceLock<crossfire::MTx<crossfire::mpsc::Array<(HoprPseudonym, HoprStartProtocol)>>>>;
1762
1763/// PIX protocol toolbox to enable [`SessionManager`] to use PIX protocol.
1764#[derive(Clone)]
1765pub struct PixToolbox {
1766    share_generator: Arc<SsaShareGenerator<HoprPixSpec>>,
1767    share_processor: Arc<SsaReconstructor<HoprPixSpec>>,
1768    pix_events: crossfire::MTx<crossfire::mpsc::Array<HoprSessionOutPixEvent>>,
1769}
1770
1771impl PixToolbox {
1772    pub fn new(
1773        share_generator: Arc<SsaShareGenerator<HoprPixSpec>>,
1774        share_processor: Arc<SsaReconstructor<HoprPixSpec>>,
1775    ) -> (Self, impl futures::Stream<Item = HoprSessionOutPixEvent>) {
1776        let (pix_events, pix_events_rx) = crossfire::mpsc::bounded_blocking_async::<HoprSessionOutPixEvent>(1024);
1777        (
1778            Self {
1779                share_generator,
1780                share_processor,
1781                pix_events,
1782            },
1783            pix_events_rx.into_stream(),
1784        )
1785    }
1786}
1787
1788/// Manages lifecycles of Sessions.
1789///
1790/// Once the manager is [started](SessionManager::start), the [`SessionManager::dispatch_message`]
1791/// should be called for each [`ApplicationData`] received by the node.
1792/// This way, the `SessionManager` takes care of proper Start sub-protocol message processing
1793/// and correct dispatch of Session-related packets to individual existing Sessions.
1794///
1795/// Secondly, the manager can initiate new outgoing sessions via [`SessionManager::new_session`],
1796/// probe sessions using [`SessionManager::ping_session`]
1797/// and list them via [`SessionManager::active_sessions`].
1798///
1799/// Since the `SessionManager` operates over the HOPR protocol,
1800/// the message transport `S` is required.
1801/// Such transport must also be `Clone`, since it will be cloned into all the created [`HoprSession`] objects.
1802///
1803/// ## SURB balancing
1804///
1805/// The manager also can take care of automatic [SURB balancing](SurbBalancerConfig) per Session.
1806///
1807/// With each packet sent from the session initiator over to the receiving party, zero to 2 SURBs might be delivered.
1808/// When the receiving party wants to send reply packets back, it must consume 1 SURB per packet. This
1809/// means that if the difference between the SURBs delivered and SURBs consumed is negative, the receiving party
1810/// might soon run out of SURBs. If SURBs run out, the reply packets will be dropped, causing likely quality of
1811/// service degradation.
1812///
1813/// In an attempt to counter this effect, there are two co-existing automated modes of SURB balancing:
1814/// *local SURB balancing* and *remote SURB balancing*.
1815///
1816/// ### Local SURB balancing
1817///
1818/// Local SURB balancing is performed on the sessions that were initiated by another party (and are
1819/// therefore incoming to us).
1820/// The local SURB balancing mechanism continuously evaluates the rate of SURB consumption and retrieval,
1821/// and if SURBs are running out, the packet egress shaping takes effect. This by itself does not
1822/// avoid the depletion of SURBs but slows it down in the hope that the initiating party can deliver
1823/// more SURBs over time. This might happen either organically by sending effective payloads that
1824/// allow non-zero number of SURBs in the packet, or non-organically by delivering KeepAlive messages
1825/// via *remote SURB balancing*. Both are governed by the initiator's estimate of our buffer, so
1826/// signalling SURB distress is what tells it to keep the organic ones coming.
1827///
1828/// The egress shaping is done automatically, unless the Session initiator sets the [`Capability::NoRateControl`]
1829/// flag during Session initiation.
1830///
1831/// ### Remote SURB balancing
1832///
1833/// Remote SURB balancing is performed by the Session initiator. The SURB balancer estimates the number of SURBs
1834/// delivered to the other party, and also the number of SURBs consumed by seeing the amount of traffic received
1835/// in replies.
1836/// When enabled, a desired target level of SURBs at the Session counterparty is set. According to measured
1837/// inflow and outflow of SURBs to/from the counterparty, the production of non-organic SURBs is started
1838/// via keep-alive messages (sent to counterparty) and is controlled to maintain that target level.
1839///
1840/// In other words, the Session initiator tries to compensate for the usage of SURBs by the counterparty by
1841/// sending new ones via the keep-alive messages.
1842///
1843/// The same estimate also governs *organic* production: once the counterparty is estimated to be at
1844/// its target, outgoing Session data packets carry no SURBs at all, and they return to one per packet
1845/// as soon as the estimate falls back below target. Without that, a balancer could correctly silence
1846/// its keep-alives while organic SURBs kept pouring into a full buffer — where each one evicts an
1847/// older SURB and destroys the PIX share it was carrying. A `SurbDistress` or `OutOfSurbs` signal
1848/// from the counterparty overrides the estimate and restores production immediately, which is what
1849/// makes it safe to act on an estimate that forward-path loss can inflate.
1850///
1851/// This mechanism is configurable via the `surb_management` field in [`SessionClientConfig`].
1852///
1853/// ### Possible scenarios
1854///
1855/// There are 4 different scenarios of local vs. remote SURB balancing configuration, but
1856/// an equilibrium (= matching the SURB production and consumption) is most likely to be reached
1857/// only when both are configured (the ideal case below):
1858///
1859/// #### 1. Ideal local and remote SURB balancing
1860///
1861/// 1. The Session recipient (Exit) set the `initial_return_session_egress_rate`, `max_surb_buffer_duration` and
1862///    `maximum_surb_buffer_size` values in the [`SessionManagerConfig`].
1863/// 2. The Session initiator (Entry) sets the [`target_surb_buffer_size`](SurbBalancerConfig) which matches the
1864///    [`maximum_surb_buffer_size`](SessionManagerConfig) of the counterparty.
1865/// 3. The Session initiator (Entry) does *NOT* set the [`Capability::NoRateControl`] capability flag when opening
1866///    Session.
1867/// 4. The Session initiator (Entry) sets [`max_surbs_per_sec`](SurbBalancerConfig) slightly higher than the
1868///    `maximum_surb_buffer_size / max_surb_buffer_duration` value configured at the counterparty.
1869///
1870/// In this situation, the maximum Session egress from Exit to the Entry is given by the
1871/// `maximum_surb_buffer_size / max_surb_buffer_duration` ratio. If there is enough bandwidth,
1872/// the (remote) SURB balancer sending SURBs to the Exit will stabilize roughly at this rate of SURBs/sec,
1873/// and the whole system will be in equilibrium during the Session's lifetime (under ideal network conditions).
1874///
1875/// #### 2. Remote SURB balancing only
1876///
1877/// 1. The Session initiator (Entry) *DOES* set the [`Capability::NoRateControl`] capability flag when opening Session.
1878/// 2. The Session initiator (Entry) sets `max_surbs_per_sec` and `target_surb_buffer_size` values in
1879///    [`SurbBalancerConfig`]
1880///
1881/// In this one-sided situation, the Entry node floods the Exit node with SURBs,
1882/// only based on its estimated consumption of SURBs at the Exit. The Exit's egress is not
1883/// rate-limited at all. If the Exit runs out of SURBs at any point in time, it will simply drop egress packets.
1884///
1885/// This configuration could potentially only lead to an equilibrium
1886/// when the `SurbBalancer` at the Entry can react fast enough to Exit's demand.
1887///
1888/// #### 3. Local SURB balancing only
1889///
1890/// 1. The Session recipient (Exit) set the `initial_return_session_egress_rate`, `max_surb_buffer_duration` and
1891///    `maximum_surb_buffer_size` values in the [`SessionManagerConfig`].
1892/// 2. The Session initiator (Entry) does *NOT* set the [`Capability::NoRateControl`] capability flag when opening
1893///    Session.
1894/// 3. The Session initiator (Entry) does *NOT* set the [`SurbBalancerConfig`] at all when opening Session.
1895///
1896/// In this one-sided situation, the Entry node does not provide any additional SURBs at all (except the
1897/// ones that are naturally carried by the egress packets which have space to hold SURBs). It relies
1898/// only on the Session egress limiting of the Exit node.
1899/// The Exit will limit the egress roughly to the rate of natural SURB occurrence in the ingress.
1900///
1901/// This configuration could potentially only lead to an equilibrium when uploading non-full packets
1902/// (ones that can carry at least a single SURB), and the Exit's egress is limiting itself to such a rate.
1903/// If Exit's egress reaches low values due to SURB scarcity, the upper layer protocols over Session might break.
1904///
1905/// #### 4. No SURB balancing on each side
1906///
1907/// 1. The Session initiator (Entry) *DOES* set the [`Capability::NoRateControl`] capability flag when opening Session.
1908/// 2. The Session initiator (Entry) does *NOT* set the [`SurbBalancerConfig`] at all when opening Session.
1909///
1910/// In this situation, no additional SURBs are being produced by the Entry and no Session egress rate-limiting
1911/// takes place at the Exit.
1912///
1913/// This configuration can only lead to an equilibrium when Entry sends non-full packets (ones that carry
1914/// at least a single SURB) and the Exit is consuming the SURBs (Session egress) at a slower or equal rate.
1915/// Such configuration is very fragile, as any disturbances in the SURB flow might lead to a packet drop
1916/// at the Exit's egress.
1917///
1918/// ### SURB decay
1919///
1920/// In a hypothetical scenario of a non-zero packet loss, the Session initiator (Entry) might send a
1921/// certain number of SURBs to the Session recipient (Exit), but only a portion of it is actually delivered.
1922/// The Entry has no way of knowing that and assumes that everything has been delivered.
1923/// A similar problem happens when the Exit uses SURBs to construct return packets, but only a portion
1924/// of those packets is actually delivered to the Entry. At this point, the Entry also subtracts
1925/// fewer SURBs from its SURB estimate at the Exit.
1926///
1927/// In both situations, the Entry thinks there are more SURBs available at the Exit than there really are.
1928///
1929/// To compensate for a potential packet loss, the Entry's estimation of Exit's SURB buffer is regularly
1930/// diminished by a percentage of the `target_surb_buffer_size`, even if no incoming traffic from the
1931/// Exit is detected.
1932///
1933/// This behavior can be controlled via the `surb_decay` field of [`SurbBalancerConfig`].
1934///
1935/// ### SURB balance and target notification
1936///
1937/// The Session recipient (Exit) can notify the Session initiator (Entry) periodically about its estimated
1938/// number of SURBs for the Session. This can help the Entry to adjust its approximation of that level so
1939/// that its Local SURB balancer can better intervene.
1940/// This can be set using the `surb_balance_notify_period` field of [`SessionManagerConfig`] for the Exit.
1941///
1942/// Likewise, the Entry can inform the Exit about its desired SURB buffer target so that the Exit
1943/// can better accommodate its Remote SURB balancing.
1944/// This can be set using the `surb_target_notify` field of the [`SessionManagerConfig`] of each new Session.
1945///
1946/// Both mechanisms leverage the Keep Alive message to report the respective values.
1947///
1948/// ## PIX (Protocol for Incentivization of eXits) Protocol Flow
1949///
1950/// When a Session is opened with the [`Capability::UsePIX`] flag, the following protocol
1951/// runs between the Entry (initiator) and Exit (recipient) to provide on-chain payment
1952/// guarantees for the data relayed through the Session.
1953///
1954/// ### 1. PIX Parameter Negotiation (Session Initiation)
1955///
1956/// During [`SessionManager::new_session`], the Entry encodes its PIX SSA (Session Stealth
1957/// Address) parameters — a [`PixParams`] quadruple of `polys_per_ssa`, `shares_per_poly`,
1958/// `surplus_shares` and the curve `suite` — into the upper 32 bits of the 64-bit options word
1959/// carried in the most significant 64 bits of the `StartSession.additional_data` field, via
1960/// [`PixParams::into_additional_data`]. The first two describe how many polynomials and shares each
1961/// SSA will use; the third is how many extra shares per polynomial the Entry emits to absorb losses. Those three define
1962/// the data quota per SSA, which is `polys × (threshold + surplus) × SESSION_MTU` — the surplus is priced in rather
1963/// than free, since a cycle emits it whether or not any share is lost (see `pix_params_to_quota`).
1964///
1965/// The fourth is not a dimension and does not enter the quota: it names the elliptic curve the
1966/// Entry's build instantiates PIX over, which fixes the width of every curve-sized field later in
1967/// the handshake. It is fixed at build time on both sides and is therefore not negotiated — the Exit
1968/// refuses anything but its own, below.
1969///
1970/// What is announced is built from the installed [`SsaShareGenerator`]'s
1971/// [`SsaGeneratorConfig`](hopr_protocol_pix::SsaGeneratorConfig), never from the caller: the
1972/// generator is what produces the shares that go on the wire, so advertising anything else would
1973/// let the Session proceed while emitting shares the Exit cannot reconstruct. The dimensions are
1974/// therefore not configurable per Session — [`Capability::UsePIX`] is the whole per-Session switch,
1975/// and a node with no PIX toolbox installed refuses the Session. That check runs before the
1976/// initiation challenge slot is reserved, so a node that cannot serve PIX at all cannot have its
1977/// challenge slots exhausted by repeated requests for it.
1978///
1979/// The caller never needs to know the resulting quota in advance: it is computed node-side and
1980/// handed back per SSA in [`AgreedSsaQuota::quota_per_ssa`], which is what sizes the deposit.
1981///
1982/// On the Exit side, `check_pix_params` validates these parameters against:
1983/// - The protocol ranges, which [`PixParams::try_from_additional_data`] enforces as it unpacks.
1984/// - The configured [`IncomingSessionPixConfig::quota_range`] (by default ≈157 MiB–630 MiB). Dynamic admission checks
1985///   the smallest `batch_size × quota_per_ssa` up to the configured maximum; fixed admission checks the per-SSA quota
1986///   itself and retains the configured exact batch.
1987/// - Optionally, [`IncomingSessionPixConfig::enforce_pix`] rejects Sessions that do not offer PIX.
1988/// - The Exit only checks the *product* `polys × (threshold + surplus)`, not the individual values, so the Entry can
1989///   split it to suit its computing power. The computation is easily parallelizable in the number of polynomials, but
1990///   not in threshold. The surplus is inside that product, so redundancy is bought rather than taken: a cycle emits
1991///   `threshold + surplus` shares per polynomial come what may, and the deposit covers all of them.
1992///
1993/// If parameters are rejected, a [`StartErrorReason::UnacceptablePixParams`] error is returned.
1994///
1995/// ### 2. Exit SSA Request (`SsaRequest` → Entry)
1996///
1997/// Once the PIX parameters are accepted, the Exit spawns a `SessionPixSupervisor`, whose opening
1998/// `RequestSsa` action has `send_ssa_request` create a new SSA commitment via the server-side
1999/// [`SsaReconstructor`]. This produces an *Exit commitment* (a group element) that is sent back to
2000/// the Entry as a [`SsaServerCommitmentMessage`].
2001///
2002/// One action, and therefore one message, carries the batch selected during admission: no more than
2003/// [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request) SSAs at
2004/// contiguous indices, sharing the single `params` field, since every SSA in a Session uses the same
2005/// dimensions. The Entry caps what it will accept at
2006/// [`SessionManagerConfig::max_ssas_per_ssa_request`], and rejects an over-cap request in full while
2007/// replying with an `UnacceptablePixParams` [`StartErrorType`], so the Exit does not have to infer the
2008/// refusal from a deadline. The default is a batch of one, which is byte-for-byte the unbatched
2009/// exchange.
2010///
2011/// From here the supervisor owns the cycle's deadlines: `max_ssa_delivery_time` for the Entry's
2012/// commitment, then `max_deposit_wait` for the funds, then the recovery deadlines. Missing any of
2013/// them closes the Session with `ClosureReason::PixFailure`. The first two are multiplied by the batch
2014/// size, because a batch asks the Entry for that many commitment sets and that many deposits before
2015/// any of them can fairly be called late.
2016///
2017/// ### 3. Entry SSA Commitment (`SsaCommit` → Exit)
2018///
2019/// Upon receiving the [`SsaServerCommitmentMessage`], the Entry's `handle_ssa_request`
2020/// generates a *client commitment* using the shared [`SsaShareGenerator`] (which is also
2021/// used by the packet pipeline to embed PIX shares into return-path SURBs). The client
2022/// commitment is combined with the Exit commitment to derive the on-chain deposit address
2023/// via [`HoprPixSpec::group_to_deposit_address`].
2024///
2025/// The Entry then sends one or more [`SsaClientCommitmentMessage`]s back to the Exit and
2026/// emits a [`HoprSessionOutPixEvent::ReadyToDeposit`] to the upper layer, signaling that
2027/// funds can be deposited at the computed address.
2028///
2029/// "One or more" is well over a hundred at the deployed dimensions — one commitment per polynomial,
2030/// chunked to packet size — and the Exit can use *none* of it until every message has landed, since
2031/// the SSA commitment is the sum of them all. A single lost packet therefore used to cost the whole
2032/// Session on `max_ssa_delivery_time`, typically after the Entry had already been told to fund the
2033/// address.
2034///
2035/// So a partially delivered commitment is repaired rather than waited out. Each arriving message
2036/// reports `SessionPixEvent::CommitmentProgress`, which arms (and re-arms) the supervisor's
2037/// [`commitment_recommit_interval`](crate::SupervisorConfig::commitment_recommit_interval); if
2038/// delivery then goes quiet with the set incomplete, `RequestCommitmentRetransmission` has
2039/// `send_commitment_retransmission_request` ask the Entry — via an `SsaRequest` whose
2040/// [`missing`](hopr_protocol_start::SsaServerCommitmentMessage::missing) scope names the polynomial
2041/// runs the reconstructor never received — to re-send exactly those. The Entry answers in
2042/// `handle_ssa_recommit` from the bytes it sent the first time, advancing nothing and announcing no
2043/// second deposit. A cycle for which *nothing* arrived never asks: it has no scope to name, and the
2044/// delivery deadline remains what ends it.
2045///
2046/// ### 4. Deposit Awaiting (Exit Side)
2047///
2048/// The Exit receives the client commitment messages in `handle_ssa_commit`, inserts the
2049/// coefficient commitments into the [`SsaReconstructor`], and extracts the deposit address.
2050/// It emits [`HoprSessionOutPixEvent::DepositNeeded`] to the upper layer with the
2051/// [`AgreedSsaQuota`] and a channel to confirm the deposit.
2052///
2053/// A verifiable commitment is what starts the deposit clock, so the supervisor is told
2054/// (`CommitmentVerified`) before anything can be reported against it. A `PixDepositObserver` task
2055/// then forwards every confirmation arriving on that channel as `DepositConfirmed`: top-ups
2056/// accumulate, and the supervisor is what decides when enough has landed. The observer carries no
2057/// timeout of its own — `max_deposit_wait` is the deadline, and a second authority racing the
2058/// first is exactly what the supervisor exists to prevent. If the upper layer drops the sender
2059/// without ever confirming, the observer reports that (`DepositObserverClosed`) rather than
2060/// letting the deadline run out on a deposit that is never coming.
2061///
2062/// Once the deposit suffices, the SSA moves to *recovering*. Its recovery deadlines start when it
2063/// reaches the front, and its deposit releases the egress gate only while it is there. When a paid
2064/// front leaves—whether recovered or retired after a tolerated failure—an unfunded successor
2065/// restores its own bounded predeposit allowance; an already funded successor stays open but starts
2066/// a fresh served-without-progress ceiling.
2067///
2068/// ### 5. SSA Collection, Recovery and Pipelining
2069///
2070/// As the Entry sends return-path SURBs during the Session, each SURB can carry a PIX
2071/// share generated from the client's polynomial set. The Exit's [`SsaReconstructor`]
2072/// collects these shares and reports how far the cycle has got via
2073/// [`HoprSessionInPixEvent::RecoveryProgress`]; `dispatch_pix_event` forwards those snapshots to
2074/// the supervisor, which uses them both to reset `max_recovery_idle` and to keep the gate serving.
2075///
2076/// When the last cycle of a batch reaches the *early recovery threshold* (≈85%), an
2077/// [`HoprSessionInPixEvent::SsaAlmostRecovered`] event fires and the supervisor answers with a
2078/// `RequestSsa` action for the next batch — pipelining the costly commitment exchange with the tail
2079/// of share collection. If that cycle is still awaiting its own commitment or deposit, the request
2080/// waits for funding; if two generations still own cycle state, it waits for one to be fully
2081/// released. Those checks are independent of the configurable share-order policy.
2082///
2083/// Once fully recovered, [`HoprSessionInPixEvent::SsaRecovered`] fires, allowing the Exit to
2084/// unlock and redeem the deposited funds. The reconstructor releases the live cycle immediately;
2085/// the supervisor retains a short tombstone, whose expiry emits `RetireSsa` to drop that cycle's
2086/// [`SsaCommitmentGuard`] and abort its deposit observer. The next batch is requested here if
2087/// `SsaAlmostRecovered` has not already done so. Observers are keyed by SSA index, so retiring one
2088/// cycle never cancels a pipelined successor's.
2089///
2090/// ### 6. Unverifiable Shares
2091///
2092/// Shares are not checked individually. Once a polynomial has collected `threshold` of them,
2093/// the reconstructor interpolates its constant term and compares it against the commitment; if
2094/// they disagree, at least one of those shares did not come from the committed polynomial and an
2095/// [`HoprSessionInPixEvent::UnverifiableShares`] event fires, carrying the reconstructor's running
2096/// total for the SSA rather than a delta.
2097///
2098/// The first such report closes the Session, unconditionally and with nothing to configure. The
2099/// failed polynomial is never retried, and the SSA is the sum of every polynomial's constant term,
2100/// so the cycle is already unrecoverable and can never pay; closing immediately caps what a
2101/// malicious Entry is served at `threshold` packets. The running total is logged rather than
2102/// accumulated. See `SessionPixSupervisor::on_unverifiable_shares` for why no tolerance is offered.
2103///
2104/// ### Configuring PIX at the Exit
2105///
2106/// The Exit configures PIX via [`IncomingSessionPixConfig`] within [`SessionManagerConfig`].
2107///
2108/// The [`PixToolbox`] (holding the [`SsaShareGenerator`] and [`SsaReconstructor`]) must
2109/// be provided via [`SessionManager::start`] for PIX to function.
2110pub struct SessionManager<S> {
2111    // Keeps track of Session initiations requests on the Client side.
2112    session_initiations: SessionInitiationCache,
2113    session_notifiers: Arc<OnceLock<SessionNotifiers>>,
2114    start_protocol_tx: StartProtocolMsgSink,
2115    /// Authoritative session count for admission control.
2116    /// Incremented atomically inside `allocate_session_slot` before the cache insertion,
2117    /// and decremented at every removal path (explicit close, eviction, guard rollback).
2118    active_sessions: Arc<std::sync::atomic::AtomicUsize>,
2119    sessions: moka::sync::Cache<SessionId, SessionSlot>,
2120    msg_sender: Arc<OnceLock<S>>,
2121    pix_toolbox: Arc<OnceLock<PixToolbox>>,
2122    cfg: SessionManagerConfig,
2123    /// Per-SessionId waiters notified when a new session slot is allocated. Lets message
2124    /// handlers that arrive before the slot insertion completes (e.g. SsaRequest vs
2125    /// SessionEstablished) await the slot once instead of busy-looping with sleeps.
2126    /// Keyed by SessionId so that only waiters for the relevant session are woken.
2127    slot_allocated: Arc<Mutex<HashMap<SessionId, Vec<oneshot::Sender<()>>>>>,
2128    /// Serialises `handle_ssa_request` per pseudonym.
2129    ///
2130    /// Start messages are processed under `for_each_concurrent`, so without this several
2131    /// `SsaRequest`s for one pseudonym run at once — and each reads the successor gate's admission
2132    /// state before any of them has advanced it. Every racer passes, and the Entry commits to (and
2133    /// funds) as many batches as the Exit cared to send, which is exactly what the gate exists to
2134    /// stop. The generator's own monotonic index is the backstop that keeps this from being a
2135    /// correctness hole, but it only rejects *equal or lower* indices: an Exit numbering its batches
2136    /// upwards races past it.
2137    ///
2138    /// An async lock rather than a blocking one because the guarded region awaits — commitment
2139    /// generation goes to a blocking pool, and publication awaits the transport.
2140    ///
2141    /// A `moka` cache rather than a map, for its idle eviction: a `HashMap` keyed by pseudonym would
2142    /// otherwise retain one entry per Session for the life of the process.
2143    ssa_request_locks: moka::future::Cache<HoprPseudonym, Arc<futures::lock::Mutex<()>>>,
2144    /// Live reconstructor-cycle bytes this node has committed to, summed over every PIX Session.
2145    ///
2146    /// The counterpart of `active_sessions`, and the reason that one is not sufficient: a Session is
2147    /// admitted on a slot count, but what it costs the reconstructor is set by the dimensions its
2148    /// peer offered. Charged in `handle_incoming_session_initiation` and returned by
2149    /// [`CycleBudgetReservation`]'s `Drop`, so no removal path has to remember to decrement it.
2150    live_cycle_bytes: Arc<std::sync::atomic::AtomicU64>,
2151    /// Where incoming Sessions are put to the session server for an admission decision.
2152    ///
2153    /// Unset on a node that runs no session server — an Entry, or a relay — where there is nobody to
2154    /// ask and every Session is admitted on this node's own terms.
2155    /// Behind a `Mutex` so every request goes through *one* sender.
2156    ///
2157    /// `futures::channel::mpsc` gives each cloned `Sender` its own guaranteed slot on top of the
2158    /// buffer, so sending through a fresh clone each time would let the queue grow with the number
2159    /// of concurrent initiations — the bound would not bound. `try_send` never blocks, so the lock
2160    /// is held only for the enqueue itself.
2161    admission_tx: Arc<OnceLock<parking_lot::Mutex<SessionAdmissionSink>>>,
2162}
2163
2164impl<S> Clone for SessionManager<S> {
2165    fn clone(&self) -> Self {
2166        Self {
2167            session_initiations: self.session_initiations.clone(),
2168            session_notifiers: self.session_notifiers.clone(),
2169            start_protocol_tx: self.start_protocol_tx.clone(),
2170            active_sessions: self.active_sessions.clone(),
2171            live_cycle_bytes: self.live_cycle_bytes.clone(),
2172            sessions: self.sessions.clone(),
2173            cfg: self.cfg.clone(),
2174            msg_sender: self.msg_sender.clone(),
2175            pix_toolbox: self.pix_toolbox.clone(),
2176            slot_allocated: Arc::clone(&self.slot_allocated),
2177            ssa_request_locks: self.ssa_request_locks.clone(),
2178            admission_tx: self.admission_tx.clone(),
2179        }
2180    }
2181}
2182
2183fn session_config(cfg: &SessionManagerConfig, capabilities: Capabilities) -> HoprSessionConfig {
2184    session_config_with(cfg, capabilities, None)
2185}
2186
2187/// As [`session_config`], with the initiating session's own head-of-line bound.
2188///
2189/// `None` inherits the node default; `Some(0)` disables the bound for this session; `Some(n)` sets
2190/// it. The zero-disables convention matches the node setting, so the per-session
2191/// and node-wide knobs cannot mean opposite things by the same value.
2192///
2193/// Sessions accepted from a peer pass `None`: the bound governs how *we* reassemble what arrives,
2194/// so it is ours to choose, not the initiator's to impose on us.
2195fn session_config_with(
2196    cfg: &SessionManagerConfig,
2197    capabilities: Capabilities,
2198    max_frames_behind_gap: Option<usize>,
2199) -> HoprSessionConfig {
2200    // Only a session that cannot recover the missing frame should abandon it early. With
2201    // retransmission the gap is a request away, so waiting is productive and cutting it short
2202    // would discard data that was on its way back; without it the wait is for something that is
2203    // never coming, and every frame behind the gap is held for nothing.
2204    let can_retransmit =
2205        capabilities.contains(Capability::RetransmissionAck) || capabilities.contains(Capability::RetransmissionNack);
2206
2207    // The session's own value when it stated one, the node's otherwise. `Some(0)` disables the
2208    // bound at either level -- a threshold of zero would abandon every gap before a single frame
2209    // arrived behind it, which nobody wants and which the sequencer would silently clamp to one.
2210    let bound = match max_frames_behind_gap.or(cfg.max_frames_behind_gap) {
2211        Some(0) | None => None,
2212        Some(n) => Some(n),
2213    };
2214
2215    HoprSessionConfig {
2216        capabilities,
2217        frame_mtu: cfg.frame_mtu,
2218        frame_timeout: cfg.max_frame_timeout,
2219        max_buffered_segments: cfg.max_buffered_segments,
2220        max_frames_behind_gap: (!can_retransmit).then_some(bound).flatten(),
2221    }
2222}
2223
2224#[cfg(feature = "telemetry")]
2225fn initialize_session_telemetry(
2226    session_id: SessionId,
2227    cfg: &SessionManagerConfig,
2228    capabilities: Capabilities,
2229    surb_estimator: Option<&AtomicSurbFlowEstimator>,
2230    surb_mgmt: Option<&Arc<BalancerStateValues>>,
2231) {
2232    initialize_session_metrics(session_id, session_config(cfg, capabilities));
2233    set_session_state(&session_id, SessionLifecycleState::Active);
2234    if let (Some(estimator), Some(mgmt)) = (surb_estimator, surb_mgmt) {
2235        set_session_balancer_data(&session_id, estimator.clone(), mgmt.clone());
2236    }
2237}
2238
2239/// Caps how many SURBs an outgoing Session data packet may carry, per the SURB balancer.
2240///
2241/// The default cap of `1` follows the balancer's target. Explicit limits of `0` or more than `1`
2242/// take precedence; `usize::MAX` leaves the packet's existing limit untouched.
2243///
2244/// Otherwise the cap comes from [`BalancerStateValues::organic_surbs_per_packet`], which yields `0`
2245/// once the counterparty is estimated to be at its target. That zero is the whole point of the
2246/// function: without it, the balancer can silence its keep-alives and still have organic SURBs
2247/// pouring into a full buffer, where each one evicts an older SURB and destroys the PIX share it
2248/// carried.
2249///
2250/// Nothing else has to change to keep the SURB accounting straight. This runs in front of the
2251/// counting sink, so `estimate_surbs_with_msg` reports the capped figure; and the PIX share for a
2252/// SURB is drawn per SURB while it is built, so a cap of `0` draws no share at all and leaves it
2253/// queued for a packet that can actually deliver it.
2254fn cap_organic_surbs(data: &mut ApplicationDataOut, max_surbs: usize, surb_mgmt: &BalancerStateValues) {
2255    if max_surbs != usize::MAX {
2256        data.packet_info.get_or_insert_default().max_surbs_in_packet = if max_surbs == 1 {
2257            surb_mgmt.organic_surbs_per_packet()
2258        } else {
2259            max_surbs
2260        };
2261    }
2262}
2263
2264/// Books an incoming packet's SURBs into the Exit's flow estimator.
2265///
2266/// `saved` SURBs entered the store and `evicted` older ones were dropped to make room for them. Both
2267/// are recorded so that `produced - consumed - evicted` tracks what the store actually holds: without
2268/// the eviction term the estimate stays inflated by every SURB the full store ever discarded, long
2269/// after they are gone -- which leaves the keep-alive level report and the PIX fill reserve both
2270/// reading a level that never falls. Split out from the receive closure so the accounting can be
2271/// unit-tested on its own.
2272fn account_incoming_surbs(estimator: &AtomicSurbFlowEstimator, saved: u64, evicted: u64) {
2273    estimator
2274        .produced
2275        .fetch_add(saved, std::sync::atomic::Ordering::Relaxed);
2276    estimator
2277        .evicted
2278        .fetch_add(evicted, std::sync::atomic::Ordering::Relaxed);
2279}
2280
2281async fn send_via_msg_sender<S, D>(
2282    msg_sender: &mut S,
2283    routing: DestinationRouting,
2284    data: D,
2285    error_context: &'static str,
2286) -> errors::Result<()>
2287where
2288    S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Unpin,
2289    S::Error: std::error::Error + Send + Sync + Clone + 'static,
2290    D: TryInto<ApplicationData>,
2291    D::Error: std::error::Error + Send + Sync + 'static,
2292{
2293    let app_data: ApplicationData = data.try_into().map_err(SessionManagerError::other)?;
2294    msg_sender
2295        .send((routing, ApplicationDataOut::with_no_packet_info(app_data)))
2296        .timeout(futures_time::time::Duration::from(EXTERNAL_SEND_TIMEOUT))
2297        .await
2298        .map_err(|_| {
2299            error!("timeout sending {error_context}");
2300            TransportSessionError::Timeout
2301        })?
2302        .map_err(|error| {
2303            error!(%error, "failed to send {error_context}");
2304            SessionManagerError::other(error)
2305        })?;
2306    Ok(())
2307}
2308
2309impl<S> SessionManager<S>
2310where
2311    S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Clone + Send + Sync + Unpin + 'static,
2312    S::Error: std::error::Error + Send + Sync + Clone + 'static,
2313{
2314    /// Creates a new instance given the [`config`](SessionManagerConfig).
2315    pub fn new(mut cfg: SessionManagerConfig) -> Self {
2316        let maximum_sessions = cfg.maximum_sessions;
2317        cfg.surb_balance_notify_period = cfg
2318            .surb_balance_notify_period
2319            .map(|p| p.max(MIN_SURB_BUFFER_NOTIFICATION_PERIOD));
2320        cfg.minimum_surb_buffer_duration = cfg.minimum_surb_buffer_duration.max(MIN_SURB_BUFFER_DURATION);
2321
2322        // Ensure the Frame MTU is at least the size of the Session segment MTU payload
2323        cfg.frame_mtu = cfg.frame_mtu.max(SESSION_MTU);
2324        cfg.max_frame_timeout = cfg.max_frame_timeout.max(MIN_FRAME_TIMEOUT);
2325
2326        // Both SSA batch knobs are range-validated in `HoprProtocolConfig`, but nothing in this crate
2327        // calls `validate()` — clamp here so a programmatically built config cannot ask for a batch of
2328        // zero (no request would ever be sent) or one large enough to blow past what
2329        // `MAX_SSA_BATCH_SIZE` exists to bound.
2330        cfg.max_ssas_per_ssa_request = cfg.max_ssas_per_ssa_request.clamp(1, MAX_SSA_BATCH_SIZE);
2331        cfg.pix_config.supervision.ssas_per_request =
2332            cfg.pix_config.supervision.ssas_per_request.clamp(1, MAX_SSA_BATCH_SIZE);
2333
2334        // Every supervisor duration, for the same reason and with more riding on it. A duration the
2335        // monotonic clock cannot represent makes `Instant::checked_add` return `None`, and every phase
2336        // reads an absent deadline as *no deadline* — so an over-large value does not produce a distant
2337        // deadline, it silently disables the rule. `validate_pix_supervision` rejects such a config, but
2338        // only a node built from a config file goes through it.
2339        //
2340        // Representability is necessary and not sufficient, though: `max_recovery_idle` also has to
2341        // stay under the reconstructor's `unused_verifier_lifetime`, or the supervisor waits on a
2342        // cycle whose state was reclaimed hours earlier. Clamping only to the monotonic-clock cap left
2343        // exactly that — `Duration::MAX` became 24 h against a 30-minute default lifetime — so the
2344        // normalized config was representable and still invalid.
2345        //
2346        // The reconstructor is not installed until `start`, so the *default* configuration is what
2347        // this can normalize against. `start` re-checks against the one actually installed, which is
2348        // where a caller pairing a non-default reconstructor is caught.
2349        //
2350        // The two batch-scaled deadlines are clamped by the scaled value, since that is what is armed;
2351        // dividing by the batch size (already clamped to at least 1 above) is what keeps the product
2352        // under the cap.
2353        {
2354            use hopr_protocol_pix::SsaReconstructorConfig;
2355
2356            let sup = &mut cfg.pix_config.supervision;
2357            let cap = crate::supervision::MAX_SUPERVISOR_DURATION;
2358            let per_cycle_cap = cap / sup.ssas_per_request as u32;
2359            sup.max_ssa_delivery_time = sup.max_ssa_delivery_time.min(per_cycle_cap);
2360            sup.max_deposit_wait = sup.max_deposit_wait.min(per_cycle_cap);
2361            sup.max_recovery_time = sup.max_recovery_time.min(cap);
2362            sup.tombstone_retention_window = sup.tombstone_retention_window.min(cap);
2363
2364            // Strictly under the lifetime, which is what `validate_pix_supervision` requires; a
2365            // saturating subtraction keeps a pathologically short lifetime from wrapping. The lower
2366            // bound is not clamped: raising a too-small value would be inventing a policy rather than
2367            // bounding one, and it is what `validate_pix_supervision` reports.
2368            let idle_cap = SsaReconstructorConfig::default()
2369                .unused_verifier_lifetime
2370                .saturating_sub(Duration::from_secs(1))
2371                .min(cap);
2372            sup.max_recovery_idle = sup.max_recovery_idle.min(idle_cap);
2373
2374            // The fill heartbeat is armed as a deadline like the rest, so it takes the same cap. The
2375            // rate ceiling is clamped for a different reason: `RateController::MIN_DELAY` makes
2376            // anything above `MAX_FILL_RATE` unrepresentable, and a controller that silently ignores
2377            // the one bound on this node's self-generated egress is worse than one that is loud about
2378            // refusing it — which is what `validate_pix_supervision` does for a config that was
2379            // validated. This branch is for the programmatic ones that never were.
2380            //
2381            // The heartbeat needs the *lower* bound for the same reason the ceiling needs its upper
2382            // one, and it is the one that bites: a zero period makes `FillRate::as_packets_per_sec`
2383            // report zero, so the planner's own floor is zero, every branch of `FillPlanner::bound`
2384            // that returns the heartbeat returns a zero period, and `PixFillControl::apply` hands
2385            // `set_rate_per_unit(1, 1µs)` to the controller — which `MIN_DELAY` turns into 10 000
2386            // packets/s. The one clamp that exists to bound self-generated egress is bypassed by the
2387            // one field that was left unbounded below. The floor is the exact dual of the ceiling —
2388            // the period of `MAX_FILL_RATE` — rather than a new number, so the two cannot drift; an
2389            // inverted heartbeat/ceiling pair is already resolved ceiling-wins inside `bound`, which
2390            // is what makes any positive floor safe here.
2391            sup.fill.heartbeat = sup
2392                .fill
2393                .heartbeat
2394                .clamp(Duration::from_secs(1) / crate::supervision::MAX_FILL_RATE, cap);
2395            sup.fill.max_rate = sup.fill.max_rate.clamp(1, crate::supervision::MAX_FILL_RATE);
2396        }
2397
2398        // The admission wait is spent inside the peer's initiation timeout, so a value at or above
2399        // it buys nothing: the peer has already given up by the time the wait expires, and the
2400        // Session was refused anyway. Clamped to one leg of that budget, leaving the other for the
2401        // Start exchange the decision is part of. Clamped rather than rejected because every other
2402        // cross-field rule in this constructor normalizes, and because the ceiling is derived from a
2403        // field the caller may also have left at its default.
2404        cfg.session_admission_timeout = cfg.session_admission_timeout.min(initiation_timeout_max_one_way(
2405            cfg.initiation_timeout_base,
2406            RoutingOptions::MAX_INTERMEDIATE_HOPS,
2407        ));
2408
2409        #[cfg(all(feature = "telemetry", not(test)))]
2410        METRIC_ACTIVE_SESSIONS.set(0.0);
2411
2412        let active_sessions: Arc<std::sync::atomic::AtomicUsize> = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2413        let active_sessions_for_listener = active_sessions.clone();
2414
2415        let msg_sender = Arc::new(OnceLock::new());
2416        let initiation_timeout =
2417            2 * initiation_timeout_max_one_way(cfg.initiation_timeout_base, RoutingOptions::MAX_INTERMEDIATE_HOPS);
2418        let pix_toolbox: Arc<OnceLock<PixToolbox>> = Arc::new(OnceLock::new());
2419        Self {
2420            msg_sender: msg_sender.clone(),
2421            session_initiations: moka::sync::Cache::builder()
2422                .max_capacity(maximum_sessions as u64)
2423                .time_to_live(initiation_timeout)
2424                .build(),
2425            sessions: moka::sync::Cache::builder()
2426                .max_capacity(maximum_sessions as u64)
2427                .time_to_idle(cfg.idle_timeout)
2428                .eviction_listener(
2429                    move |session_id: Arc<SessionId>, entry: SessionSlot, reason| match &reason {
2430                        moka::notification::RemovalCause::Expired | moka::notification::RemovalCause::Size => {
2431                            trace!(?session_id, ?reason, "session evicted from the cache");
2432                            // Reconstructor state is released by `close_session` aborting the PIX
2433                            // action driver: the driver owns an `SsaCommitmentGuard` per live cycle,
2434                            // and dropping its future drops them. That bounds the release to the
2435                            // cycles actually in flight, where enumerating every index this Session
2436                            // had ever used was unbounded in its lifetime.
2437                            active_sessions_for_listener.fetch_sub(1, Ordering::Relaxed);
2438                            close_session(*session_id.as_ref(), entry, ClosureReason::Eviction);
2439                        }
2440                        _ => {}
2441                    },
2442                )
2443                .build(),
2444            pix_toolbox,
2445            session_notifiers: Arc::new(OnceLock::new()),
2446            start_protocol_tx: Arc::new(OnceLock::new()),
2447            active_sessions,
2448            live_cycle_bytes: Arc::new(std::sync::atomic::AtomicU64::new(0)),
2449            cfg,
2450            slot_allocated: Arc::new(Mutex::new(HashMap::new())),
2451            // Idle rather than live TTL, and generous: the entry only has to outlive the requests that
2452            // race for it, and one evicted between two cycles is simply recreated by the next request.
2453            // Matches the generator's own per-pseudonym cache, which is what the guarded region reads.
2454            ssa_request_locks: moka::future::Cache::builder()
2455                .time_to_idle(Duration::from_secs(1800))
2456                .build(),
2457            admission_tx: Arc::new(OnceLock::new()),
2458        }
2459    }
2460
2461    /// Starts the instance with the given `msg_sender` `Sink`
2462    /// and a channel `new_session_notifier` used to notify when a new incoming session is opened to us.
2463    ///
2464    /// Optionally, the PIX processor and event sink can be provided for handling PIX protocol.
2465    /// If not specified, the `SessionManager` will not handle PIX protocol.
2466    ///
2467    /// `admission` is where incoming Sessions are put to the session server for a per-Session
2468    /// decision before they are established. Without it — an Entry, or a relay, or any caller that
2469    /// installed no session server — every Session is admitted on this node's own configured terms,
2470    /// which is the behaviour of a node that does not distinguish between targets.
2471    ///
2472    /// This method must be called prior to any calls to [`SessionManager::new_session`] or
2473    /// [`SessionManager::dispatch_message`].
2474    pub fn start<T>(
2475        &self,
2476        msg_sender: S,
2477        new_session_notifier: T,
2478        pix: Option<PixToolbox>,
2479        admission: Option<SessionAdmissionSink>,
2480    ) -> errors::Result<Vec<AbortHandle>>
2481    where
2482        T: futures::Sink<IncomingSession> + Send + 'static,
2483        T::Error: std::error::Error + Send + Sync + 'static,
2484    {
2485        // Every fallible check runs before the first `OnceLock::set` below.
2486        //
2487        // `start` publishes the manager's started state through those locks, and a `OnceLock` cannot
2488        // be un-set. Validating after `msg_sender` had been filled left a manager that reports
2489        // `is_started() == false` — no workers, no notifier — and yet refuses every retry with
2490        // `AlreadyStarted`: neither running nor recoverable, out of what is an ordinary configuration
2491        // error the caller could have corrected and retried on the same instance.
2492        if let Some(pix) = pix.as_ref() {
2493            validate_incoming_session_pix_config(&self.cfg.pix_config, MAX_ASSUMED_SESSION_PACKET_RATE)?;
2494
2495            // The authoritative cross-component check, and the first moment it can be made: the
2496            // supervisor's deadlines are meaningless without the reconstructor lifetimes they race
2497            // against, and the reconstructor arrives here rather than at construction.
2498            //
2499            // `SessionManager::new` normalizes against the *default* reconstructor config, which is
2500            // what makes the common programmatic case correct without an API break. A caller that
2501            // pairs a supervisor config with a non-default reconstructor is caught here instead — as
2502            // an error rather than a clamp, because at this point both halves were chosen
2503            // deliberately and silently overriding one of them would be the wrong answer.
2504            crate::supervision::validate_pix_supervision(
2505                &self.cfg.pix_config.supervision,
2506                pix.share_processor.config(),
2507            )?;
2508        }
2509
2510        self.msg_sender
2511            .set(msg_sender)
2512            .map_err(|_| SessionManagerError::AlreadyStarted)?;
2513
2514        if let Some(pix) = pix {
2515            self.pix_toolbox
2516                .set(pix)
2517                .map_err(|_| SessionManagerError::AlreadyStarted)?;
2518        }
2519
2520        if let Some(admission) = admission {
2521            self.admission_tx
2522                .set(parking_lot::Mutex::new(admission))
2523                .map_err(|_| SessionManagerError::AlreadyStarted)?;
2524        }
2525
2526        // Re-map the user-provided sink errors to `SessionManagerError` and erase the concrete
2527        //  type so that the `SessionManager` does not need to be generic over it. This also avoids
2528        // having to spawn a separate task to forward items between channels: senders simply lock
2529        // the sink and send directly.
2530        let new_session_notifier: BoxSink<IncomingSession> =
2531            Box::pin(new_session_notifier.sink_map_err(SessionManagerError::other));
2532        let new_session_notifier = Arc::new(hopr_utils::runtime::prelude::Mutex::new(new_session_notifier));
2533
2534        let (session_close_tx, session_close_rx) =
2535            crossfire::mpsc::bounded_blocking_async(self.cfg.maximum_sessions + 10);
2536        self.session_notifiers
2537            .set((new_session_notifier, session_close_tx))
2538            .map_err(|_| SessionManagerError::AlreadyStarted)?;
2539
2540        let (start_protocol_tx, start_protocol_rx) =
2541            crossfire::mpsc::bounded_blocking_async(start_protocol_channel_capacity(&self.cfg));
2542        let _ = self.start_protocol_tx.set(start_protocol_tx);
2543
2544        let myself = self.clone();
2545        let closure_diag = hopr_utils::runtime::diagnostics::ConcurrentDiagnostics::new(
2546            "session_close_for_each_concurrent",
2547            module_path!(),
2548            file!(),
2549            line!(),
2550        );
2551        let ah_closure_notifications = hopr_utils::spawn_as_abortable_named!(
2552            "session_close_notifications",
2553            session_close_rx.into_stream().for_each_concurrent(
2554                self.cfg.maximum_sessions + 10,
2555                move |(session_id, closure_reason)| {
2556                    let myself = myself.clone();
2557                    let closure_diag = closure_diag.clone();
2558                    closure_diag.wrap(|| {
2559                        // These notifications come from the Sessions themselves once
2560                        // an empty read is encountered, which means the closure was done by the
2561                        // other party.
2562                        //
2563                        // Routed through `close_session_with_reason` rather than removing the slot
2564                        // here, so that this path — the one an Exit-side session server actually
2565                        // closes on — can answer with a SURB drain like every other. Identical
2566                        // behaviour when it does not.
2567                        if !myself.close_session_with_reason(&session_id, closure_reason) {
2568                            // Do not treat this as an error
2569                            debug!(
2570                                ?session_id,
2571                                ?closure_reason,
2572                                "could not find session id to close, maybe the session is already closed"
2573                            );
2574                        }
2575                        futures::future::ready(())
2576                    })
2577                }
2578            )
2579        );
2580
2581        // This is necessary to evict expired entries from the caches if
2582        // no session-related operations happen at all.
2583        // This ensures the dangling expired sessions are properly closed
2584        // and their closure is timely notified to the other party.
2585        let myself = self.clone();
2586        let ah_session_expiration = hopr_utils::spawn_as_abortable!(async move {
2587            let jitter = hopr_api::types::crypto_random::random_float_in_range(1.0..1.5);
2588            let timeout = 2 * initiation_timeout_max_one_way(
2589                myself.cfg.initiation_timeout_base,
2590                RoutingOptions::MAX_INTERMEDIATE_HOPS,
2591            )
2592            .min(myself.cfg.idle_timeout)
2593            .mul_f64(jitter)
2594                / 2;
2595            futures_time::stream::interval(timeout.into())
2596                .for_each(|_| async {
2597                    trace!("executing session cache evictions");
2598                    myself.sessions.run_pending_tasks();
2599                    myself.session_initiations.run_pending_tasks();
2600                })
2601                .await;
2602        });
2603
2604        // Begin processing of Start protocol messages
2605        let myself = self.clone();
2606        let ah_start_protocol = hopr_utils::spawn_as_abortable_named!(
2607            "session_start_protocol_processor",
2608            start_protocol_rx.into_stream().for_each_concurrent(
2609                Some(self.cfg.maximum_sessions + 10),
2610                move |(pseudonym, protocol_msg)| {
2611                    let myself = myself.clone();
2612                    async move {
2613                        let result = match protocol_msg {
2614                            HoprStartProtocol::StartSession(session_req) => {
2615                                myself.handle_incoming_session_initiation(pseudonym, session_req).await
2616                            }
2617                            HoprStartProtocol::SessionEstablished(est) => myself.handle_session_established(est).await,
2618                            HoprStartProtocol::SessionError(error_type) => {
2619                                myself.handle_session_error(error_type).await
2620                            }
2621                            HoprStartProtocol::KeepAlive(msg) => myself.handle_keep_alive(msg).await,
2622                            HoprStartProtocol::SsaCommit(client_commit_msg) => {
2623                                myself.handle_ssa_commit(pseudonym, client_commit_msg).await
2624                            }
2625                            HoprStartProtocol::SsaRequest(server_commit_msg) => {
2626                                myself.handle_ssa_request(pseudonym, server_commit_msg).await
2627                            }
2628                        };
2629
2630                        if let Err(error) = result {
2631                            error!(%error, "failed to process Start protocol message");
2632                        }
2633                    }
2634                }
2635            )
2636        );
2637
2638        Ok(vec![ah_closure_notifications, ah_session_expiration, ah_start_protocol])
2639    }
2640
2641    /// Check if [`start`](SessionManager::start) has been called and the instance is running.
2642    pub fn is_started(&self) -> bool {
2643        self.session_notifiers.get().is_some()
2644    }
2645
2646    /// Atomically allocates a new [`SessionSlot`] for `session_id` and returns an RAII
2647    /// [`SessionSlotGuard`] for it.
2648    ///
2649    /// Establishing a session involves several fallible steps *after* the slot has been
2650    /// inserted. The returned guard rolls the slot back - tearing the partially
2651    /// established session down via [`close_session`] - unless it is
2652    /// [committed](SessionSlotGuard::commit).
2653    ///
2654    /// The active-sessions gauge is incremented here, atomically with the insertion and
2655    /// the guard creation, precisely so that it is always paired with the guard's
2656    /// rollback decrement (performed through [`close_session`]). This keeps the gauge
2657    /// accurate: it is never decremented for a slot that was not counted in the first
2658    /// place, and every counted slot is decremented exactly once when it leaves the cache.
2659    ///
2660    /// Returns `None` if a slot for `session_id` already exists; in that case nothing is
2661    /// inserted, the gauge is left untouched, and no guard is produced. The atomic `entry`
2662    /// API guarantees that only one concurrent caller can claim the slot for a given
2663    /// pseudonym (avoiding a TOCTOU race), which also rules out loopback sessions onto
2664    /// ourselves.
2665    ///
2666    /// Capacity is enforced by an atomic counter incremented *before* the cache insertion,
2667    /// making it impossible for two concurrent callers (with different session IDs) to both
2668    /// succeed when the cache is already at `maximum_sessions`.
2669    fn allocate_session_slot(&self, session_id: SessionId, slot: SessionSlot) -> Option<SessionSlotGuard<'_>> {
2670        // Try to claim a session slot before touching the cache. `fetch_update` atomically
2671        // increments only if the value is strictly below the limit, preventing two concurrent
2672        // callers from both succeeding when already at capacity.
2673        let counter = &self.active_sessions;
2674        #[allow(clippy::incompatible_msrv)]
2675        let did_reserve = counter
2676            .try_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
2677                (n < self.cfg.maximum_sessions).then_some(n + 1)
2678            })
2679            .is_ok();
2680
2681        if !did_reserve {
2682            return None;
2683        }
2684
2685        let result =
2686            self.sessions
2687                .entry(session_id)
2688                .and_compute_with(|entry: Option<moka::Entry<SessionId, SessionSlot>>| {
2689                    if entry.is_none() {
2690                        moka::ops::compute::Op::Put(slot)
2691                    } else {
2692                        // Duplicate key — release the reservation so the counter stays accurate.
2693                        counter.fetch_sub(1, Ordering::Relaxed);
2694                        moka::ops::compute::Op::Nop
2695                    }
2696                });
2697
2698        match result {
2699            moka::ops::compute::CompResult::Inserted(_) => {
2700                // Notify any waiting message handler (e.g. handle_ssa_request) that the slot
2701                // is now available. Drain and signal the senders registered for this SessionId.
2702                if let Some(waiters) = self
2703                    .slot_allocated
2704                    .lock()
2705                    .unwrap_or_else(|e| e.into_inner())
2706                    .remove(&session_id)
2707                {
2708                    for w in waiters {
2709                        let _ = w.send(());
2710                    }
2711                }
2712                // take_guard borrows self, so the guard stores the counter clone separately.
2713                Some(SessionSlotGuard::new(&self.sessions, session_id, counter.clone()))
2714            }
2715            _ => None,
2716        }
2717    }
2718
2719    /// Initiates a new outgoing Session to `destination` with the given configuration.
2720    ///
2721    /// If the Session's counterparty does not respond within
2722    /// the [configured](SessionManagerConfig) period,
2723    /// this method returns [`TransportSessionError::Timeout`].
2724    ///
2725    /// It will also fail if the instance has not been [started](SessionManager::start).
2726    pub async fn new_session(
2727        &self,
2728        destination: Address,
2729        target: SessionTarget,
2730        cfg: SessionClientConfig,
2731    ) -> errors::Result<HoprSession> {
2732        self.sessions.run_pending_tasks();
2733        if self.cfg.maximum_sessions <= self.active_sessions.load(Ordering::Relaxed) {
2734            return Err(SessionManagerError::TooManySessions.into());
2735        }
2736
2737        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
2738
2739        let (tx_initiation_done, rx_initiation_done): (
2740            crossfire::MTx<crossfire::mpsc::One<_>>,
2741            crossfire::AsyncRx<crossfire::mpsc::One<_>>,
2742        ) = crossfire::mpsc::build(crossfire::mpsc::One::new());
2743
2744        let current_ssa_state = Arc::new(OnceLock::new());
2745
2746        let mut additional_data = 0_u64;
2747
2748        // SURB balancer target announcement is encoded in the lower 32-bits of the options word
2749        if !cfg.capabilities.contains(Capability::NoRateControl) {
2750            additional_data |= cfg
2751                .surb_management
2752                .map(|c| c.target_surb_buffer_size)
2753                .unwrap_or(
2754                    self.cfg.initial_return_session_egress_rate as u64
2755                        * self
2756                            .cfg
2757                            .minimum_surb_buffer_duration
2758                            .max(MIN_SURB_BUFFER_DURATION)
2759                            .as_secs(),
2760                )
2761                .min(u32::MAX as u64);
2762        }
2763
2764        // PIX quota parameter announcement is encoded in the upper 32-bits of the options word.
2765        // Run these validations BEFORE reserving the initiation challenge slot so that a
2766        // repeated invalid request cannot exhaust all challenge slots.
2767        if cfg.capabilities.contains(Capability::UsePIX) {
2768            // PIX requires at least 1 intermediate hop on the return path so that PIX
2769            // shares can be encrypted with the first relayer's ticket-challenge solution
2770            // (`HalfKey`) and delivered via return-path SURBs. With 0 intermediate hops
2771            // (a direct Exit→Entry SURB), there is no relayer to provide the challenge
2772            // solution, so shares are never embedded — the ongoing PIX share delivery
2773            // mechanism is dead and the Exit's quota is never replenished.
2774            if cfg.return_path_options.count_hops() == 0 {
2775                return Err(SessionManagerError::Other(anyhow!(
2776                    "UsePIX requires at least 1 intermediate hop on the return path, got 0"
2777                ))
2778                .into());
2779            }
2780
2781            // Validate that PIX toolbox is available before advertising UsePIX
2782            let pix_toolbox = self
2783                .pix_toolbox
2784                .get()
2785                .ok_or_else(|| SessionManagerError::Other(anyhow!("UsePIX requested but no PIX toolbox installed")))?;
2786
2787            // The installed generator is what actually produces the shares that go on the wire, so
2788            // it — not the caller — is the source of the announced parameters. Letting a caller name
2789            // them instead would allow a Session to proceed while producing shares the Exit cannot
2790            // reconstruct, so the dimensions are not a per-Session choice at all: `UsePIX` is the
2791            // whole switch, and everything downstream (the per-SSA quota, and so the deposit amount
2792            // reported to the funding strategy in `AgreedSsaQuota`) is derived from what follows.
2793            // The same source for the dimensions and for the curve suite: `HoprPixSpec` is what the
2794            // installed generator is instantiated over, so the announced suite cannot disagree with
2795            // the one that will actually produce the shares.
2796            let gen_cfg = pix_toolbox.share_generator.config();
2797            let params = PixParams::try_from_config::<HoprPixSpec>(gen_cfg)
2798                .map_err(|error| SessionManagerError::Other(anyhow!("invalid PIX dimensions: {error}")))?;
2799
2800            let _ = current_ssa_state.set(SessionSsaState::new(params));
2801            additional_data = params.into_additional_data(additional_data as u32);
2802        }
2803
2804        let (challenge, _) = insert_into_next_slot(
2805            &self.session_initiations,
2806            |ch| {
2807                if let Some(challenge) = ch {
2808                    ((challenge + 1) % hopr_api::types::crypto_random::MAX_RANDOM_INTEGER).max(MIN_CHALLENGE)
2809                } else {
2810                    hopr_api::types::crypto_random::random_integer(MIN_CHALLENGE, None)
2811                }
2812            },
2813            |_| tx_initiation_done,
2814            Some(self.cfg.maximum_sessions as u64),
2815        )
2816        .ok_or(SessionManagerError::NoChallengeSlots)?; // almost impossible with u64
2817
2818        // Prepare the session initiation message in the Start protocol
2819        trace!(challenge, ?cfg, "initiating session with config");
2820        let start_session_msg = HoprStartProtocol::StartSession(StartInitiation {
2821            challenge,
2822            target,
2823            capabilities: HoprSessionCapabilities(cfg.capabilities),
2824            additional_data: into_initiation_additional_data(additional_data),
2825        });
2826
2827        let pseudonym = cfg.pseudonym.unwrap_or(HoprPseudonym::random());
2828        let forward_routing = DestinationRouting::Forward {
2829            destination: Box::new(destination.into()),
2830            pseudonym: Some(pseudonym), // Session must use a fixed pseudonym already
2831            forward_options: cfg.forward_path_options.clone(),
2832            return_options: cfg.return_path_options.clone().into(),
2833        };
2834
2835        // Send the Session initiation message
2836        info!(challenge, %pseudonym, %destination, "new session request");
2837        send_via_msg_sender(
2838            &mut msg_sender,
2839            forward_routing.clone(),
2840            start_session_msg,
2841            "session request message",
2842        )
2843        .await
2844        .map_err(|error| {
2845            self.session_initiations.remove(&challenge);
2846            TransportSessionError::packet_sending(error)
2847        })?;
2848
2849        // The timeout is given by the number of hops requested
2850        let initiation_timeout: futures_time::time::Duration = initiation_timeout_max_one_way(
2851            self.cfg.initiation_timeout_base,
2852            cfg.forward_path_options.count_hops() + cfg.return_path_options.count_hops() + 2,
2853        )
2854        .into();
2855
2856        // Await session establishment response from the Exit node or timeout
2857
2858        trace!(challenge, "awaiting session establishment");
2859        match rx_initiation_done
2860            .into_stream()
2861            .try_next()
2862            .timeout(initiation_timeout)
2863            .await
2864        {
2865            Ok(Ok(Some(est))) => {
2866                // Session has been established, construct it
2867                let session_id = est.session_id;
2868                debug!(challenge = est.orig_challenge, ?session_id, "started a new session");
2869
2870                let (session_tx, session_rx) =
2871                    crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
2872                let (session_rx, session_rx_ah) = hopr_utils::runtime::DropAbortable::new(session_rx.into_stream());
2873
2874                let mut abort_handles = AbortableList::default();
2875                abort_handles.insert(SessionHandles::Ingress, session_rx_ah);
2876
2877                let notifier = self
2878                    .session_notifiers
2879                    .get()
2880                    .map(|(_, notifier)| {
2881                        let notifier = notifier.clone();
2882                        Box::new(move |session_id: SessionId, reason: ClosureReason| {
2883                            let _ = notifier
2884                                .try_send((session_id, reason))
2885                                .inspect_err(|error| error!(%session_id, %error, "failed to notify session closure"));
2886                        })
2887                    })
2888                    .ok_or(SessionManagerError::NotStarted)?;
2889
2890                // NOTE: the Exit node can have different `max_surb_buffer_size`
2891                // setting on the Session manager, so it does not make sense to cap it here
2892                // with our maximum value.
2893                if let Some(balancer_config) = cfg.surb_management {
2894                    let surb_estimator = AtomicSurbFlowEstimator::default();
2895                    let surbs_per_keep_alive = KeepAliveMessage::from(session_id).max_surbs()? as u32;
2896
2897                    // Sender responsible for keep-alive and Session data will be counting produced SURBs
2898                    let surb_estimator_clone = surb_estimator.clone();
2899                    let full_surb_scoring_sender =
2900                        msg_sender.with(move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
2901                            let produced = data.estimate_surbs_with_msg() as u64;
2902                            // Count how many SURBs we sent with each packet
2903                            surb_estimator_clone
2904                                .produced
2905                                .fetch_add(produced, std::sync::atomic::Ordering::Relaxed);
2906                            #[cfg(feature = "telemetry")]
2907                            telemetry::record_session_surb_produced(&session_id, produced);
2908                            futures::future::ok::<_, S::Error>((routing, data))
2909                        });
2910
2911                    let surb_mgmt = Arc::new(BalancerStateValues::from(balancer_config));
2912                    // The counterparty's store is the same bounded ring buffer as ours, so its
2913                    // capacity bounds what our `produced - consumed` estimate can legitimately
2914                    // claim it is holding.
2915                    surb_mgmt.set_counterparty_buffer_capacity(self.cfg.maximum_surb_buffer_size as u64);
2916
2917                    // For standard Session data we first reduce the number of SURBs we want to produce,
2918                    // unless requested to always max them out
2919                    let max_surbs_in_packet = cfg.max_surbs_per_data_packet;
2920                    let surb_mgmt_for_tx = surb_mgmt.clone();
2921                    let reduced_surb_scoring_sender = full_surb_scoring_sender.clone().with(
2922                        // NOTE: this is put in-front of the `full_surb_scoring_sender`,
2923                        // so that its estimate of SURBs gets automatically updated based on
2924                        // the `max_surbs_in_packets` set here.
2925                        move |(routing, mut data): (DestinationRouting, ApplicationDataOut)| {
2926                            cap_organic_surbs(&mut data, max_surbs_in_packet, &surb_mgmt_for_tx);
2927                            futures::future::ok::<_, S::Error>((routing, data))
2928                        },
2929                    );
2930
2931                    // Spawn the SURB-bearing keep alive stream towards the Exit. Suspended until the
2932                    // balancer below gives the controller a rate.
2933                    let ka_controller = RateController::new(0, Duration::from_secs(1));
2934                    let ka_abort_handle = utils::spawn_keep_alive_stream(
2935                        session_id,
2936                        full_surb_scoring_sender,
2937                        forward_routing.clone(),
2938                        if self.cfg.surb_target_notify {
2939                            SurbNotificationMode::Target
2940                        } else {
2941                            SurbNotificationMode::DoNotNotify
2942                        },
2943                        surb_mgmt.clone(),
2944                        &ka_controller,
2945                        // The Entry's balancer owns this stream outright and there is no second
2946                        // producer to arbitrate between, so every packet the rate limiter releases is
2947                        // one the balancer asked for.
2948                        || true,
2949                    );
2950                    abort_handles.insert(SessionHandles::KeepAlive, ka_abort_handle);
2951
2952                    // Spawn the SURB balancer, which will decide on the initial SURB rate.
2953                    debug!(%session_id, ?balancer_config ,"spawning entry SURB balancer");
2954                    let balancer = SurbBalancer::new(
2955                        session_id,
2956                        // The setpoint and output limit is immediately reconfigured by the SurbBalancer
2957                        PidBalancerController::from_gains(PidControllerGains::from_env_or_default()),
2958                        surb_estimator.clone(),
2959                        // Convert the requested SURB rate to packets using the keep-alive's capacity.
2960                        SurbControllerWithCorrection(ka_controller, surbs_per_keep_alive),
2961                        surb_mgmt.clone(),
2962                    );
2963
2964                    let (level_stream, balancer_abort_handle) =
2965                        balancer.start_control_loop(self.cfg.balancer_sampling_interval);
2966                    abort_handles.insert(SessionHandles::Balancer, balancer_abort_handle);
2967
2968                    let returned_packets = Arc::new(std::sync::atomic::AtomicU64::new(0));
2969
2970                    // Insert the slot before the SURB readiness wait so any echo packets that
2971                    // arrive during pre-loading are accepted rather than dropped as unknown.
2972                    // Early return from the wait below drops slot_guard uncommitted, which removes
2973                    // the slot and calls close_session → abort_all() on the spawned tasks.
2974                    let mut slot_guard = self
2975                        .allocate_session_slot(
2976                            session_id,
2977                            SessionSlot {
2978                                session_tx,
2979                                routing_opts: forward_routing.clone(),
2980                                abort_handles: Arc::new(parking_lot::Mutex::new(abort_handles)),
2981                                surb_mgmt: surb_mgmt.clone(),
2982                                surb_estimator: surb_estimator.clone(),
2983                                current_ssa_state,
2984                                // Entry side: the Exit is authoritative for the PIX lifecycle, so
2985                                // there is no supervisor here and nothing gates egress.
2986                                pix_supervisor: Default::default(),
2987                                pix_egress_gate: Default::default(),
2988                                pix_fill: Default::default(),
2989                                returned_packets: returned_packets.clone(),
2990                                // Nor does it hold reconstructor state: the live-cycle budget is
2991                                // charged by the side that reconstructs.
2992                                cycle_budget: None,
2993                            },
2994                        )
2995                        .ok_or_else(|| {
2996                            // Session already exists; it means it is most likely a loopback attempt
2997                            error!(%session_id, "session already exists - loopback attempt");
2998                            SessionManagerError::Loopback
2999                        })?;
3000
3001                    // Prevent the slot from being evicted by time_to_idle while SURBs are
3002                    // pre-loading. Each `get()` call resets the idle timer; the task is
3003                    // aborted as soon as the readiness wait resolves.
3004                    let sessions_keepalive = self.sessions.clone();
3005                    let touch_period = (self.cfg.idle_timeout / 2).max(self.cfg.min_session_touch_period);
3006                    let slot_keepalive = hopr_utils::runtime::prelude::spawn(async move {
3007                        loop {
3008                            hopr_utils::runtime::prelude::sleep(touch_period).await;
3009                            let _ = sessions_keepalive.get(&session_id);
3010                        }
3011                    });
3012
3013                    // TODO: consider making this interactive = other party reports the exact level periodically
3014                    let wait_result = level_stream
3015                        .skip_while(|current_level| {
3016                            futures::future::ready(*current_level < balancer_config.target_surb_buffer_size / 2)
3017                        })
3018                        .next()
3019                        .timeout(futures_time::time::Duration::from(SESSION_READINESS_TIMEOUT))
3020                        .await;
3021                    slot_keepalive.abort();
3022                    match wait_result {
3023                        Ok(Some(surb_level)) => {
3024                            info!(%session_id, surb_level, "session is ready");
3025                        }
3026                        Ok(None) => {
3027                            return Err(
3028                                SessionManagerError::other(anyhow!("surb balancer was cancelled prematurely")).into(),
3029                            );
3030                        }
3031                        Err(_) => {
3032                            warn!(%session_id, "session didn't reach target SURB buffer size in time");
3033                        }
3034                    }
3035
3036                    #[cfg(all(feature = "telemetry", not(test)))]
3037                    METRIC_NUM_INITIATED_SESSIONS.increment();
3038
3039                    let surb_estimator_for_rx = surb_estimator.clone();
3040                    let session = HoprSession::new_with_surb_state(
3041                        session_id,
3042                        forward_routing,
3043                        session_config_with(&self.cfg, cfg.capabilities, cfg.max_frames_behind_gap),
3044                        (
3045                            reduced_surb_scoring_sender,
3046                            session_rx.inspect(move |_| {
3047                                // The Exit's SURB signals are recorded in `dispatch_message`, upstream
3048                                // of this: they override the organic SURB gate, and a packet dropped
3049                                // before it reaches here would take the override with it.
3050
3051                                // Received packets = SURB consumption estimate
3052                                // The received packets always consume a single SURB.
3053                                surb_estimator_for_rx
3054                                    .consumed
3055                                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3056                                // Same event, separate counter, on purpose — see `returned_packets`.
3057                                returned_packets.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3058                                #[cfg(feature = "telemetry")]
3059                                telemetry::record_session_surb_consumed(&session_id, 1);
3060                            }),
3061                        ),
3062                        Some(notifier),
3063                        // Entry (sending) side: give flow control the SURB balancer state as its
3064                        // anti-grief down-only ceiling, and the client's opt-in flow-control config.
3065                        Some(surb_mgmt.clone()),
3066                        cfg.flow_control,
3067                    )?;
3068
3069                    #[cfg(feature = "telemetry")]
3070                    initialize_session_telemetry(
3071                        session_id,
3072                        &self.cfg,
3073                        cfg.capabilities,
3074                        Some(&surb_estimator),
3075                        Some(&surb_mgmt),
3076                    );
3077
3078                    slot_guard.commit();
3079                    Ok(session)
3080                } else {
3081                    // Routine for short-lived sessions (health checks, bridges); not a fault.
3082                    tracing::debug!(%session_id, "session ready without SURB balancing");
3083
3084                    // Counted here too, unlike `surb_estimator`: the PIX successor gate reads this,
3085                    // and a knob that binds on some Sessions and not others is how a deposit gate
3086                    // silently becomes a gate that never opens.
3087                    let returned_packets = Arc::new(std::sync::atomic::AtomicU64::new(0));
3088                    let returned_packets_for_rx = returned_packets.clone();
3089
3090                    // Disabled SURB management: a default state has a zero target, which reads as
3091                    // `is_disabled()` and so caps organic SURBs at one per packet — this branch's
3092                    // behaviour, expressed through the same policy the balancing branch uses rather
3093                    // than restated as a literal below.
3094                    let surb_mgmt: Arc<BalancerStateValues> = Default::default();
3095
3096                    // Insert the slot and obtain a guard that rolls it back if any
3097                    // subsequent setup step fails.
3098                    let mut slot_guard = self
3099                        .allocate_session_slot(
3100                            session_id,
3101                            SessionSlot {
3102                                session_tx,
3103                                routing_opts: forward_routing.clone(),
3104                                abort_handles: Arc::new(parking_lot::Mutex::new(abort_handles)),
3105                                surb_mgmt: surb_mgmt.clone(),
3106                                surb_estimator: Default::default(), // No SURB estimator needed
3107                                current_ssa_state,
3108                                // Entry side: the Exit is authoritative for the PIX lifecycle.
3109                                pix_supervisor: Default::default(),
3110                                pix_egress_gate: Default::default(),
3111                                pix_fill: Default::default(),
3112                                returned_packets,
3113                                // Nor does it hold reconstructor state: the live-cycle budget is
3114                                // charged by the side that reconstructs.
3115                                cycle_budget: None,
3116                            },
3117                        )
3118                        .ok_or_else(|| {
3119                            // Session already exists; it means it is most likely a loopback attempt
3120                            error!(%session_id, "session already exists - loopback attempt");
3121                            SessionManagerError::Loopback
3122                        })?;
3123
3124                    #[cfg(all(feature = "telemetry", not(test)))]
3125                    METRIC_NUM_INITIATED_SESSIONS.increment();
3126
3127                    // For standard Session data we first reduce the number of SURBs we want to produce,
3128                    // unless requested to always max them out
3129                    let max_surbs_in_packet = cfg.max_surbs_per_data_packet;
3130                    let surb_mgmt_for_tx = surb_mgmt.clone();
3131                    let reduced_surb_sender =
3132                        msg_sender.with(move |(routing, mut data): (DestinationRouting, ApplicationDataOut)| {
3133                            cap_organic_surbs(&mut data, max_surbs_in_packet, &surb_mgmt_for_tx);
3134                            futures::future::ok::<_, S::Error>((routing, data))
3135                        });
3136
3137                    let session = HoprSession::new(
3138                        session_id,
3139                        forward_routing,
3140                        session_config_with(&self.cfg, cfg.capabilities, cfg.max_frames_behind_gap),
3141                        (
3142                            reduced_surb_sender,
3143                            session_rx.inspect(move |_| {
3144                                returned_packets_for_rx.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3145                            }),
3146                        ),
3147                        Some(notifier),
3148                    )?;
3149
3150                    #[cfg(feature = "telemetry")]
3151                    initialize_session_telemetry(session_id, &self.cfg, cfg.capabilities, None, None);
3152
3153                    slot_guard.commit();
3154                    Ok(session)
3155                }
3156            }
3157            Ok(Ok(None)) => {
3158                self.session_initiations.remove(&challenge);
3159                Err(SessionManagerError::other(anyhow!(
3160                    "internal error: sender has been closed without completing the session establishment"
3161                ))
3162                .into())
3163            }
3164            Ok(Err(error)) => {
3165                // The other side did not allow us to establish a session
3166                let challenge = match error.identifier {
3167                    ErrorIdentifier::Challenge(c) => c,
3168                    ErrorIdentifier::SessionId(_) => {
3169                        // This arm should only ever receive pre-establishment errors.
3170                        // Log the id if it matters for debugging.
3171                        0
3172                    }
3173                };
3174                error!(
3175                    %challenge, ?error,
3176                    "the other party rejected the session initiation with error"
3177                );
3178                Err(TransportSessionError::Rejected(error.reason))
3179            }
3180            Err(_) => {
3181                // Timeout waiting for a session establishment
3182                error!(challenge, "session initiation attempt timed out");
3183
3184                #[cfg(all(feature = "telemetry", not(test)))]
3185                METRIC_RECEIVED_SESSION_ERRS.increment(&["timeout"]);
3186
3187                self.session_initiations.remove(&challenge);
3188                Err(TransportSessionError::Timeout)
3189            }
3190        }
3191    }
3192
3193    /// Sends a keep-alive packet with the given [`SessionId`].
3194    ///
3195    /// This currently "fires & forgets" and does not expect nor await any "pong" response.
3196    pub async fn ping_session(&self, id: &SessionId) -> errors::Result<()> {
3197        if let Some(session_data) = self.sessions.get(id) {
3198            trace!(session_id = ?id, "pinging manually session");
3199            let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3200            send_via_msg_sender(
3201                &mut msg_sender,
3202                session_data.routing_opts.clone(),
3203                HoprStartProtocol::KeepAlive((*id).into()),
3204                "session ping message",
3205            )
3206            .await
3207            .map_err(TransportSessionError::packet_sending)
3208        } else {
3209            Err(SessionManagerError::NonExistingSession.into())
3210        }
3211    }
3212
3213    /// Asks the deposit pool for the data to attach to a batch of SSAs, and collects its answers.
3214    async fn request_deposit_data(
3215        pix_toolbox: &PixToolbox,
3216        session_id: SessionId,
3217        ssa_indices: &[SsaIndex],
3218    ) -> errors::Result<HoprPixDepositData> {
3219        let deposit_ids = ssa_indices
3220            .iter()
3221            .map(|&ssa_index| PixAddressId::new(&session_id, ssa_index))
3222            .collect::<Vec<_>>();
3223
3224        let (deposit_data_created, deposit_data_rx) = futures::channel::mpsc::channel(ssa_indices.len().max(1));
3225
3226        if pix_toolbox
3227            .pix_events
3228            .try_send(HoprSessionOutPixEvent::DepositDataRequest(PixDepositDataRequest {
3229                deposit_ids,
3230                deposit_data_created,
3231            }))
3232            .is_err()
3233        {
3234            return Err(SessionManagerError::MissingDepositData(format!(
3235                "session {session_id} has no listener for the deposit data request"
3236            ))
3237            .into());
3238        }
3239
3240        let mut outstanding = ssa_indices
3241            .iter()
3242            .map(|&ssa_index| PixAddressId::new(&session_id, ssa_index))
3243            .collect::<std::collections::HashSet<_>>();
3244        let requested = outstanding.len();
3245
3246        let mut deposit_data = HoprPixDepositData::with_capacity(requested);
3247        {
3248            let replies = deposit_data_rx
3249                .take(2 * requested)
3250                .take_until(futures_time::task::sleep(futures_time::time::Duration::from(
3251                    DEPOSIT_DATA_REQUEST_TIMEOUT,
3252                )));
3253            futures::pin_mut!(replies);
3254            while let Some(entry) = replies.next().await {
3255                outstanding.remove(&entry.id);
3256                deposit_data.push(entry);
3257                if outstanding.is_empty() {
3258                    break;
3259                }
3260            }
3261        }
3262
3263        // Counted here rather than left to `deposit_data_for_batch`, which sees the same shortfall
3264        // but cannot tell a pool that went quiet from one that answered about the wrong SSAs. Both
3265        // are fatal; only one of them is fixed by making the pool faster.
3266        if !outstanding.is_empty() {
3267            return Err(SessionManagerError::MissingDepositData(format!(
3268                "session {session_id} got {} of {requested} requested deposit data entries within \
3269                 {DEPOSIT_DATA_REQUEST_TIMEOUT:?} ({} replies read in total)",
3270                requested - outstanding.len(),
3271                deposit_data.len(),
3272            ))
3273            .into());
3274        }
3275
3276        Ok(deposit_data)
3277    }
3278
3279    /// Spawns the task that carries out one Session's supervisor actions.
3280    ///
3281    /// The supervisor decides; this executes. Keeping the two apart is what lets the state machine
3282    /// be pure and exhaustively tested, and it means every side effect a PIX Session can have is
3283    /// visible in one `match`.
3284    ///
3285    /// The task owns an [`SsaCommitmentGuard`] per SSA it has requested and one Session retirement
3286    /// scope. Terminal actions retire through the frontier before releasing their guard; aborting
3287    /// the task drops every remaining guard and then the Session's compact frontier. No state grows
3288    /// with the number of cycles the Session completed.
3289    fn spawn_pix_action_driver(
3290        &self,
3291        session_id: SessionId,
3292        slot: &SessionSlot,
3293        action_rx: ActionRx,
3294        reply_routing: DestinationRouting,
3295    ) -> hopr_utils::runtime::AbortHandle {
3296        let myself = self.clone();
3297        let gate = slot
3298            .pix_egress_gate
3299            .get()
3300            .cloned()
3301            .expect("the gate is installed before the driver is spawned");
3302        let share_processor = self
3303            .pix_toolbox
3304            .get()
3305            .expect("the PIX toolbox is installed before a PIX action driver is spawned")
3306            .share_processor
3307            .clone();
3308        let retirement_scope = share_processor.begin_retirement_scope(session_id);
3309
3310        hopr_utils::spawn_as_abortable!(async move {
3311            let _retirement_scope = retirement_scope;
3312            // The vector owns each registration until a terminal action explicitly retires it or
3313            // dropping this future releases it during whole-Session teardown.
3314            let mut owned_ssas: Vec<SsaCommitmentGuard<HoprPixSpec>> = Vec::new();
3315
3316            let close_reason = loop {
3317                let Ok(action) = action_rx.recv().await else {
3318                    // The worker is gone without having said why, which is a failure of the
3319                    // supervisor itself rather than of the Session it was watching.
3320                    break Some(SessionPixCloseReason::SupervisorUnavailable);
3321                };
3322
3323                match action {
3324                    SessionPixAction::RequestSsa { ssa_ids, params } => {
3325                        let Some(slot) = myself.sessions.get(&session_id) else {
3326                            // The Session went away underneath us; report the failure so the
3327                            // supervisor stops waiting on an action that can never complete.
3328                            myself
3329                                .report_ssa_request(
3330                                    &session_id,
3331                                    None,
3332                                    SessionPixAction::RequestSsa { ssa_ids, params },
3333                                    false,
3334                                )
3335                                .await;
3336                            continue;
3337                        };
3338                        match myself.send_ssa_request(session_id, &slot, &ssa_ids, params).await {
3339                            Ok(guards) => {
3340                                owned_ssas.extend(guards);
3341                                // One confirmation per index: the supervisor arms each cycle's
3342                                // commitment deadline on its own `SsaRequestSent`, and they were all
3343                                // put on the wire by the one send that just succeeded.
3344                                if let Some(supervisor) = slot.pix_supervisor.get() {
3345                                    for ssa_id in &ssa_ids {
3346                                        if supervisor
3347                                            .send_event(SessionPixEvent::SsaRequestSent(*ssa_id))
3348                                            .await
3349                                            .is_err()
3350                                        {
3351                                            error!(%session_id, "pix supervisor stopped accepting events");
3352                                            break;
3353                                        }
3354                                    }
3355                                }
3356                            }
3357                            Err(error) => {
3358                                // Every guard was dropped by the early return inside
3359                                // `send_ssa_request`, so the whole batch of indices is released — none
3360                                // of them is left registered for a cycle that will never be asked for.
3361                                error!(
3362                                    %session_id, %error, batch_size = ssa_ids.len(),
3363                                    "failed to send ssa request"
3364                                );
3365
3366                                // A pool that could not supply deposit data ends the Session here
3367                                // rather than reporting the failure onwards. The other failures are
3368                                // transient or already self-correcting, and the supervisor is the
3369                                // right judge of them; this one is neither. The data only ever
3370                                // travels in the `SsaRequest` that carries the commitments, so it
3371                                // cannot be supplied later, and the pool will not answer a retry it
3372                                // did not answer the first time.
3373                                //
3374                                // Named rather than folded into the supervisor's `PixFailure`,
3375                                // because it points at this node's own pool while every reason the
3376                                // supervisor has points at the peer — and the Entry is told with a
3377                                // reason it can act on rather than the generic one.
3378                                if matches!(
3379                                    error,
3380                                    TransportSessionError::Manager(SessionManagerError::MissingDepositData(_))
3381                                ) {
3382                                    myself.stop_pix_fill(&session_id);
3383                                    gate.poison();
3384                                    owned_ssas.clear();
3385                                    myself
3386                                        .notify_session_error(
3387                                            session_id,
3388                                            reply_routing.clone(),
3389                                            StartErrorReason::UnacceptablePixParams,
3390                                            "session error due to missing deposit data",
3391                                        )
3392                                        .await;
3393                                    myself.close_session_with_reason(&session_id, ClosureReason::MissingDepositData);
3394                                    return;
3395                                }
3396
3397                                if let Some(supervisor) = slot.pix_supervisor.get()
3398                                    && supervisor
3399                                        .send_action_result(
3400                                            // Only the discriminant and the ids are read on the
3401                                            // failure path, so the dimensions are echoed back
3402                                            // rather than zeroed — `PixParams` has no zero value,
3403                                            // and inventing one would be a lie about what was sent.
3404                                            SessionPixAction::RequestSsa { ssa_ids, params },
3405                                            false,
3406                                        )
3407                                        .await
3408                                        .is_err()
3409                                {
3410                                    error!(%session_id, "pix supervisor stopped accepting results");
3411                                }
3412                            }
3413                        }
3414                    }
3415                    SessionPixAction::RequestCommitmentRetransmission { ssa_id, params } => {
3416                        // Failure is logged and dropped rather than reported back. Unlike
3417                        // `RequestSsa` this action is not a step the Session depends on: it repeats
3418                        // on its own interval, and the commitment deadline is still the backstop, so
3419                        // a dropped ask costs one interval rather than the Session.
3420                        if let Some(slot) = myself.sessions.get(&session_id)
3421                            && let Err(error) = myself
3422                                .send_commitment_retransmission_request(session_id, &slot, ssa_id, params)
3423                                .await
3424                        {
3425                            error!(%session_id, %ssa_id, %error, "failed to re-request ssa commitment parts");
3426                        }
3427                    }
3428                    SessionPixAction::ReleaseService => {
3429                        #[cfg(feature = "telemetry")]
3430                        crate::telemetry::set_pix_gate_mode(&session_id, true);
3431                    }
3432                    SessionPixAction::WithholdService => {
3433                        #[cfg(feature = "telemetry")]
3434                        crate::telemetry::set_pix_gate_mode(&session_id, false);
3435                    }
3436                    // Gate control is applied synchronously by the supervisor worker. The action
3437                    // reaches this I/O driver only to preserve observability and ordering.
3438                    SessionPixAction::ProgressNotification => {}
3439                    SessionPixAction::SetFillRate(rate) => {
3440                        // Looked up per action rather than captured, because the Session may already
3441                        // be gone: the supervisor plans against state it holds itself, and the slot
3442                        // can be evicted underneath it. A missing slot needs no rate — its stream is
3443                        // already aborted.
3444                        if let Some(slot) = myself.sessions.get(&session_id)
3445                            && let Some(fill) = slot.pix_fill.get()
3446                        {
3447                            fill.set_fill(rate);
3448                            // Inside the guard for the same reason the lookup is: `close_session`
3449                            // removes this Session's metric state and *then* aborts the tasks, and
3450                            // the abort is not synchronous, so a queued `SetFillRate` can still be
3451                            // processed afterwards. Setting the gauge there would re-create the
3452                            // `session_id` label series with nothing left to remove it again.
3453                            #[cfg(feature = "telemetry")]
3454                            crate::telemetry::set_pix_fill_rate(&session_id, rate.as_packets_per_sec());
3455                        }
3456                    }
3457                    SessionPixAction::RetireSsa(ssa_id) => {
3458                        share_processor.retire_ssa(ssa_id);
3459                        owned_ssas.retain(|guard| guard.ssa_id() != Some(&ssa_id));
3460                        if let Some(slot) = myself.sessions.get(&session_id) {
3461                            slot.abort_handles
3462                                .lock()
3463                                .abort_one(&SessionHandles::PixDepositObserver(ssa_id.ssa_index().get()));
3464                        }
3465                    }
3466                    SessionPixAction::Close(reason) => break Some(reason),
3467                }
3468            };
3469
3470            let Some(reason) = close_reason else { return };
3471
3472            // Read before `stop_pix_fill`, which silences the stream either way. Keyed on the drain
3473            // rather than on the reason, which is a strict superset of it: a *stalled* drain closes
3474            // with `RecoveryIdle` or `RecoveryDeadline`, and that is likewise the ordinary end of a
3475            // Session an operator has already closed rather than something to page anyone about.
3476            let draining = myself
3477                .sessions
3478                .get(&session_id)
3479                .is_some_and(|slot| slot.pix_fill.get().is_some_and(|fill| fill.is_draining()));
3480            if draining {
3481                info!(%session_id, %reason, "pix supervisor ended the post-close SURB drain");
3482            } else {
3483                error!(%session_id, %reason, "pix supervisor closed the session");
3484            }
3485
3486            // Unblock anything parked on the gate before tearing down: the supervisor that would
3487            // have woken it is the thing that just stopped. Fill is silenced alongside it, and for a
3488            // sharper reason — the gate holds packets back, while fill would go on *originating*
3489            // them, return-routed to a pseudonym whose SURBs are about to be gone.
3490            myself.stop_pix_fill(&session_id);
3491            gate.poison();
3492            owned_ssas.clear();
3493
3494            #[cfg(feature = "telemetry")]
3495            crate::telemetry::pix::record_pix_closure(reason);
3496
3497            // Return this Session's share of the node-level aggregates now, rather than leaving it
3498            // to `close_session` below. The notification between here and there is a network send
3499            // with its own timeout, and for the whole of it the Session is closed — its supervisor
3500            // has stopped and its gate is poisoned — while `hopr_pix_sessions_active` would still
3501            // be counting it. Idempotent, so `close_session` remains the backstop for every other
3502            // path into it.
3503            if let Some(slot) = myself.sessions.get(&session_id)
3504                && let Some(supervisor) = slot.pix_supervisor.get()
3505            {
3506                supervisor.telemetry.release();
3507            }
3508
3509            // Tell the Entry, so it can drop its side rather than wait out its own timeout. The
3510            // Session is closed either way, so a send failure here changes nothing.
3511            //
3512            // Except after a drain, where the Session the report would be about is already gone and
3513            // the report is return-routed: sending it would spend a SURB out of a buffer that may
3514            // already be empty, which is precisely the origination this whole path exists to avoid.
3515            // It would also be new behaviour rather than preserved behaviour, since an Exit-side
3516            // close sends the Entry nothing today — and `draining` is set before the supervisor's
3517            // verdict, so this covers a refused drain as well as a completed one.
3518            if !draining {
3519                myself.notify_pix_failure(session_id, reply_routing).await;
3520            }
3521
3522            if let Some(slot) = myself.sessions.remove(&session_id) {
3523                myself.active_sessions.fetch_sub(1, Ordering::Relaxed);
3524                close_session(session_id, slot, ClosureReason::PixFailure);
3525            }
3526        })
3527    }
3528
3529    /// Starts the Exit's single Exit → Entry keep-alive stream and installs the handle that steers it.
3530    ///
3531    /// Called for every PIX Session, and for any Session with a configured
3532    /// `surb_balance_notify_period` — the SURB-level notification and PIX fill are the same packet
3533    /// sent for different reasons, so they share one stream and one rate controller. See
3534    /// [`PixFillControl`].
3535    ///
3536    /// Returns without spawning anything when neither producer applies, which is an Exit Session that
3537    /// is not PIX on a node that has switched the notification off. Such a Session originates nothing,
3538    /// which is what it did before this existed.
3539    ///
3540    /// Deliberately not passed through the PIX egress gate. These packets carry no payload; gating
3541    /// them would let an exhausted predeposit budget silence the very signal the Entry needs in order
3542    /// to fund the Session, turning a stall into a teardown. The bound that does apply is
3543    /// [`PixFillControl::admit`]'s SURB reserve, which is about this node's own ability to send at
3544    /// all rather than about what the peer has paid for.
3545    ///
3546    /// `announced_surb_target` is the SURB buffer target the Entry asked for, and it is what that
3547    /// reserve is derived against — see [`effective_surb_reserve`]. It has to be, because the
3548    /// configured reserve is an absolute count while the buffer it is measured against is the Entry's
3549    /// choice; a Session whose Entry asks for a shallower buffer than the configured reserve could
3550    /// otherwise never be filled at all.
3551    fn spawn_exit_keep_alive(
3552        &self,
3553        session_id: SessionId,
3554        slot: &SessionSlot,
3555        msg_sender: S,
3556        is_pix: bool,
3557        announced_surb_target: u64,
3558    ) {
3559        let notify_period = self.cfg.surb_balance_notify_period;
3560        if notify_period.is_none() && !is_pix {
3561            return;
3562        }
3563
3564        let configured_reserve = self.cfg.pix_config.supervision.fill.min_surb_reserve;
3565        let min_surb_reserve = effective_surb_reserve(configured_reserve, announced_surb_target);
3566
3567        // Created here and shared: the sink wrapper below has to hold the same control the stream is
3568        // rate-limited by, so that it can tell a notification packet from a fill packet.
3569        let controller = RateController::new(0, Duration::from_secs(1));
3570        let control = Arc::new(PixFillControl::new(
3571            session_id,
3572            controller.clone(),
3573            notify_period,
3574            min_surb_reserve,
3575            slot.surb_estimator.clone(),
3576        ));
3577
3578        let control_for_admission = control.clone();
3579        let surb_estimator = slot.surb_estimator.clone();
3580        let ka_abort_handle = utils::spawn_keep_alive_stream(
3581            session_id,
3582            // Consumption is counted in the sink rather than at the rate limiter, so it counts what
3583            // was actually sent: a packet the admission predicate dropped consumed no SURB, and
3584            // counting it would walk the Exit's own level estimate down until it starved itself of
3585            // the very reserve that dropped it.
3586            msg_sender.with(move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
3587                surb_estimator
3588                    .consumed
3589                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3590                #[cfg(feature = "telemetry")]
3591                telemetry::record_session_surb_consumed(&session_id, 1);
3592                futures::future::ok::<_, S::Error>((routing, data))
3593            }),
3594            slot.routing_opts.clone(),
3595            SurbNotificationMode::Level(slot.surb_estimator.clone()),
3596            slot.surb_mgmt.clone(),
3597            &controller,
3598            move || control_for_admission.admit(),
3599        );
3600
3601        slot.abort_handles
3602            .lock()
3603            .insert(SessionHandles::KeepAlive, ka_abort_handle);
3604        let _ = slot.pix_fill.set(control.clone());
3605
3606        if let Some(period) = notify_period {
3607            // Delayed by one period, as it always was: a Session that closes immediately should not
3608            // have sent a level report about SURBs it never used. Fill is not held behind this — the
3609            // supervisor's first rate applies the moment it is planned.
3610            //
3611            // Registered so `close_session` can abort it. It is a one-shot, but it holds a clone of
3612            // the control for the whole period, and nothing bounds how long an operator may make that.
3613            let notify_delay = hopr_utils::spawn_as_abortable!(async move {
3614                hopr_utils::runtime::prelude::sleep(period).await;
3615                control.start_notify();
3616            });
3617            slot.abort_handles
3618                .lock()
3619                .insert(SessionHandles::SurbNotifyDelay, notify_delay);
3620        }
3621
3622        // An order of magnitude is the point at which the operator's intent and the Session's shape
3623        // have stopped being about the same deployment. A configured reserve of `R` is a reserve
3624        // sized for a buffer of `4R`; the derivation only lowers it tenfold once the Entry announces
3625        // under `4R / 10`, so this fires exactly on a Session whose buffer is an order of magnitude
3626        // shallower than the one this node was configured against. Fill still runs — a derived
3627        // reserve is strictly better than one that refuses every packet forever — but it is worth
3628        // saying out loud once, when the stream starts, rather than once per withheld packet.
3629        if min_surb_reserve.saturating_mul(10) < configured_reserve {
3630            warn!(
3631                %session_id,
3632                configured = configured_reserve,
3633                effective = min_surb_reserve,
3634                announced_surb_target,
3635                "the PIX fill SURB reserve was lowered by more than an order of magnitude to fit the SURB buffer \
3636                 target the entry announced for this session"
3637            );
3638        }
3639
3640        debug!(
3641            %session_id, ?notify_period, is_pix,
3642            surb_reserve = min_surb_reserve,
3643            announced_surb_target,
3644            "started the exit keep-alive stream"
3645        );
3646    }
3647
3648    /// Silences a Session's PIX fill, leaving its SURB-level notification alone.
3649    ///
3650    /// Called on every path that is about to tear a Session down. `close_session` aborts the stream
3651    /// outright, so this is not what stops it — it is what stops the *extra* traffic in the window
3652    /// between a supervisor deciding the Session is over and the teardown reaching the stream. Every
3653    /// packet originated in that window is return-routed to a pseudonym whose SURBs are gone, and one
3654    /// packet the routing resolver cannot resolve holds up every packet this node originates.
3655    fn stop_pix_fill(&self, session_id: &SessionId) {
3656        if let Some(slot) = self.sessions.get(session_id)
3657            && let Some(fill) = slot.pix_fill.get()
3658        {
3659            fill.set_fill(FillRate::ZERO);
3660        }
3661    }
3662
3663    /// Reports an action outcome to a Session's supervisor, if it still has one.
3664    async fn report_ssa_request(
3665        &self,
3666        session_id: &SessionId,
3667        slot: Option<&SessionSlot>,
3668        action: SessionPixAction,
3669        ok: bool,
3670    ) {
3671        let slot = match slot {
3672            Some(slot) => Some(slot.clone()),
3673            None => self.sessions.get(session_id),
3674        };
3675        if let Some(slot) = slot
3676            && let Some(supervisor) = slot.pix_supervisor.get()
3677            && supervisor.send_action_result(action, ok).await.is_err()
3678        {
3679            error!(%session_id, "pix supervisor stopped accepting results");
3680        }
3681    }
3682
3683    /// Best-effort `SessionError` for an already-established Session, identified by its `SessionId`.
3684    ///
3685    /// Best-effort in the strict sense: the outcome is logged and discarded. Every caller is on a path
3686    /// that has already decided the Session is over, so there is nothing a failed send could change —
3687    /// and the peer has a deadline of its own as the backstop either way. What the notice buys is
3688    /// promptness and attribution: the peer tears down in about a round trip, with the reason in its
3689    /// log, instead of waiting out a timer that names the timer rather than the cause.
3690    ///
3691    /// The peer's `handle_session_error` closes the Session on a `SessionId`-identified error and sends
3692    /// nothing back, so there is no error exchange to loop.
3693    async fn notify_session_error(
3694        &self,
3695        session_id: SessionId,
3696        routing: DestinationRouting,
3697        reason: StartErrorReason,
3698        context: &'static str,
3699    ) {
3700        let Some(mut msg_sender) = self.msg_sender.get().cloned() else {
3701            warn!(%session_id, %reason, context, "cannot send session error - manager not started");
3702            return;
3703        };
3704        match send_via_msg_sender(
3705            &mut msg_sender,
3706            routing,
3707            HoprStartProtocol::SessionError(StartErrorType {
3708                identifier: ErrorIdentifier::SessionId(session_id),
3709                reason,
3710            }),
3711            context,
3712        )
3713        .await
3714        {
3715            Ok(()) => {
3716                #[cfg(all(feature = "telemetry", not(test)))]
3717                METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
3718            }
3719            Err(error) => warn!(%session_id, %reason, %error, context, "failed to send session error"),
3720        }
3721    }
3722
3723    /// Best-effort notice to the Entry that this Session died for PIX reasons.
3724    async fn notify_pix_failure(&self, session_id: SessionId, reply_routing: DestinationRouting) {
3725        self.notify_session_error(
3726            session_id,
3727            reply_routing,
3728            StartErrorReason::Unknown,
3729            "session error after pix failure",
3730        )
3731        .await;
3732    }
3733
3734    /// Registers an Exit commitment for every index in `ssa_indices` and asks the Entry to commit to
3735    /// the matching SSAs — all in a single [`SsaServerCommitmentMessage`].
3736    ///
3737    /// Driven by the supervisor's [`RequestSsa`](SessionPixAction::RequestSsa) action, which is what
3738    /// allocates the indices and fixes the dimensions — this only carries it out. Taking them as
3739    /// arguments rather than re-reading them from the slot keeps one source of truth for what was
3740    /// negotiated: the commitments registered here and the parameters the message advertises cannot
3741    /// disagree with what the supervisor is timing.
3742    ///
3743    /// One message for the whole batch, and one `params` field covering all of it, which is correct:
3744    /// every SSA in a Session uses the same negotiated dimensions. The Entry enforces its own ceiling
3745    /// on how many it will accept ([`SessionManagerConfig::max_ssas_per_ssa_request`]) and refuses an
3746    /// over-cap batch in full, so a batch larger than the peer allows loses the Session — see
3747    /// [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request).
3748    ///
3749    /// Installs no deadline of its own: every timeout that used to be armed here now belongs to the
3750    /// supervisor, which can see the whole cycle rather than just this one step.
3751    ///
3752    /// The returned [`SsaCommitmentGuard`]s own their registrations until the caller transfers or drops
3753    /// them. Handing them out rather than retaining them here is what makes the failure path safe:
3754    /// this function has fallible steps after the registrations exist — including partway through the
3755    /// batch — and an early return from any of them releases every one of them. That matters more for
3756    /// a batch than for a single SSA: without it one failed send would strand every index it had
3757    /// registered, and since the supervisor does not reuse an index, the Session could never recover.
3758    async fn send_ssa_request(
3759        &self,
3760        session_id: SessionId,
3761        slot: &SessionSlot,
3762        ssa_ids: &[SsaId<HoprPseudonym>],
3763        params: PixParams,
3764    ) -> errors::Result<Vec<SsaCommitmentGuard<HoprPixSpec>>> {
3765        let Some(first_ssa_id) = ssa_ids.first().copied() else {
3766            return Err(SessionManagerError::Other(anyhow!("ssa request with no indices")).into());
3767        };
3768
3769        let pix_toolbox = self.pix_toolbox.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3770        // Clone kept on this task: `pix_toolbox` itself is moved into the blocking commitment task,
3771        // while the deposit-data request below needs it here.
3772        let pix_toolbox_local = pix_toolbox.clone();
3773        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3774
3775        // One blocking task for the whole batch rather than one per SSA: each commitment is a single
3776        // random scalar and one generator multiplication, so per-task overhead would dominate.
3777        let ids = ssa_ids.to_vec();
3778        let (exit_commitments, guards) = hopr_utils::parallelize::cpu::spawn_blocking(
3779            move || {
3780                let mut commitments = Vec::with_capacity(ids.len());
3781                let mut guards = Vec::with_capacity(ids.len());
3782                for ssa_id in ids {
3783                    let (commitment, guard) = pix_toolbox
3784                        .share_processor
3785                        .new_guarded_exit_commitment(ssa_id, params)?;
3786                    commitments.push((ssa_id.ssa_index(), HoprPixGroupElement(commitment.to_bytes())));
3787                    guards.push(guard);
3788                }
3789                Ok::<_, hopr_protocol_pix::errors::PixError<HoprPseudonym>>((commitments, guards))
3790            },
3791            "server_ssa_commitment",
3792        )
3793        .await
3794        .map_err(SessionManagerError::other)?
3795        .map_err(SessionManagerError::PixError)?;
3796
3797        info!(
3798            %session_id, batch_size = ssa_ids.len(), %params, %first_ssa_id,
3799            "generated exit commitments for the SSA batch"
3800        );
3801
3802        // Ask the deposit pool for the data to attach to this batch, and keep what it produced: the
3803        // Exit needs it twice. Once now, to put on the wire, and once per SSA later, to hand back to
3804        // the pool with the deposit address in `DepositNeeded` — by which point the batch it came
3805        // from is long gone.
3806        //
3807        // A pool that does not answer fails the batch here, which drops `guards` and releases every
3808        // registration it made. That is the only safe direction: deposit data reaches the Entry in
3809        // this message or not at all, so a batch sent without it buys service the Entry can never
3810        // pay for.
3811        let ssa_indices = ssa_ids.iter().map(|ssa_id| ssa_id.ssa_index()).collect::<Vec<_>>();
3812        let deposit_data = Self::request_deposit_data(&pix_toolbox_local, session_id, &ssa_indices).await?;
3813
3814        if !self.sessions.contains_key(&session_id) {
3815            return Err(SessionManagerError::NonExistingSession.into());
3816        }
3817
3818        let deposit_data = deposit_data_for_batch(&session_id, &ssa_indices, deposit_data)?;
3819        if let Some(ssa_state) = slot.current_ssa_state.get() {
3820            ssa_state.remember_sent_deposit_data(&deposit_data);
3821        }
3822
3823        // Construct and send the Exit SSA commitment request message.
3824        // The parameters were previously verified to be acceptable.
3825        let data = HoprStartProtocol::SsaRequest(SsaServerCommitmentMessage::new(
3826            session_id,
3827            params,
3828            exit_commitments,
3829            deposit_data,
3830        ));
3831
3832        send_via_msg_sender(
3833            &mut msg_sender,
3834            slot.routing_opts.clone(),
3835            data,
3836            "session SSA commitment request message",
3837        )
3838        .await
3839        .map_err(TransportSessionError::packet_sending)?;
3840
3841        Ok(guards)
3842    }
3843
3844    /// Asks the Entry to re-send the parts of one SSA's commitment that never arrived.
3845    ///
3846    /// The Entry's answer to an `SsaRequest` is hundreds of packets and the reconstructor can use
3847    /// none of it until every one has landed, so a single drop used to cost the Session on
3848    /// `max_ssa_delivery_time` — after the Entry had already been told to fund the cycle. This is the
3849    /// repair: the reconstructor says which polynomial runs are missing, and the Entry re-sends
3850    /// exactly those.
3851    ///
3852    /// Deliberately *not* built on [`send_ssa_request`](Self::send_ssa_request), whose every step is
3853    /// wrong here. It registers no Exit commitment — the cycle already has one, and registering a
3854    /// second for the same index is refused as a duplicate — allocates no index, and asks the deposit
3855    /// pool for nothing, since the deposit data of this cycle travelled with the request that created
3856    /// it. Nothing it does can earn the Entry a deposit, which is what makes it safe to repeat.
3857    ///
3858    /// The scope is read here rather than carried in the action: what is missing changes as the burst
3859    /// continues to arrive, and the freshest answer is both smaller and the one that converges.
3860    async fn send_commitment_retransmission_request(
3861        &self,
3862        session_id: SessionId,
3863        slot: &SessionSlot,
3864        ssa_id: SsaId<HoprPseudonym>,
3865        params: PixParams,
3866    ) -> errors::Result<()> {
3867        let pix_toolbox = self.pix_toolbox.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3868        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3869
3870        // Asked of the encoder, so the scope cannot be sized against a restated layout — see
3871        // `StartProtocol::max_missing_runs`.
3872        let max_runs = HoprStartProtocol::max_missing_runs(&session_id).map_err(SessionManagerError::other)?;
3873
3874        let Some(runs) = pix_toolbox.share_processor.missing_commitment_runs(&ssa_id, max_runs) else {
3875            // The registration is gone — retired, or expired after
3876            // `incomplete_commitment_lifetime` — so there is nothing left for a retransmission to
3877            // complete. The commitment deadline is what ends the Session; saying so here is the only
3878            // way an operator learns which of the two it was.
3879            warn!(%session_id, %ssa_id, "cannot re-request commitment parts for a cycle the reconstructor no longer holds");
3880            return Ok(());
3881        };
3882
3883        if runs.is_empty() {
3884            // The commitment completed between the timer firing and this running. Its
3885            // `CommitmentVerified` is already on its way to the supervisor, which clears the timer.
3886            debug!(%session_id, %ssa_id, "commitment completed before its parts could be re-requested");
3887            return Ok(());
3888        }
3889
3890        let missing_polynomials: u32 = runs.iter().map(|(first, last)| (last - first) as u32 + 1).sum();
3891        info!(
3892            %session_id, %ssa_id, runs = runs.len(), missing_polynomials,
3893            "re-requesting the SSA commitment parts that did not arrive"
3894        );
3895
3896        send_via_msg_sender(
3897            &mut msg_sender,
3898            slot.routing_opts.clone(),
3899            HoprStartProtocol::SsaRequest(SsaServerCommitmentMessage::recommit(
3900                session_id,
3901                params,
3902                [(ssa_id.ssa_index(), runs)],
3903            )),
3904            "ssa commitment retransmission request",
3905        )
3906        .await
3907        .map_err(TransportSessionError::packet_sending)?;
3908
3909        Ok(())
3910    }
3911
3912    /// Returns the current number of active sessions.
3913    pub fn num_active_sessions(&self) -> usize {
3914        self.active_sessions.load(Ordering::Relaxed)
3915    }
3916
3917    /// Returns [`SessionIds`](SessionId) of all currently active sessions.
3918    pub fn active_sessions(&self) -> Vec<SessionId> {
3919        self.sessions.run_pending_tasks();
3920        self.sessions.iter().map(|(k, _)| *k).collect()
3921    }
3922
3923    /// Explicitly closes the session with the given `id`.
3924    ///
3925    /// Removes the entry from the internal session cache, closes the data channel,
3926    /// and aborts any auxiliary tasks. Returns `true` if a session was found and
3927    /// closed, `false` otherwise.
3928    ///
3929    /// This avoids waiting for the idle timeout (`time_to_idle`) or the LRU
3930    /// capacity bound to evict the entry, which is the desired behaviour when
3931    /// the caller (e.g. REST `DELETE /session`) knows the session is finished.
3932    ///
3933    /// One case departs from that: an Exit-side PIX Session with
3934    /// [`fill.drain_after_close`](crate::PixFillConfig::drain_after_close) on and spendable SURBs
3935    /// left starts a SURB drain instead of being torn down (see `begin_surb_drain`). The data path
3936    /// still ends at once, but the session slot and the keep-alive stream stay in place until the
3937    /// supervisor closes the Session — whether because the funded cycle recovered or because the
3938    /// drain was refused — and `true` then means "found, and now draining" rather than "found and
3939    /// closed".
3940    pub fn close_session(&self, id: &SessionId) -> bool {
3941        self.close_session_with_reason(id, ClosureReason::Eviction)
3942    }
3943
3944    /// Answers an Exit-side close by draining the Session's buffered SURBs, when that is worth doing.
3945    ///
3946    /// A PIX Session can end long before the cycle it was funded for does. The deposit that cycle was
3947    /// paid is released only once its whole emission has ridden back to the Entry, and the address it
3948    /// sits at derives from both nodes' commitments — so tearing the Session down mid-cycle strands
3949    /// money both sides have already parted with. When the Exit still holds enough SURBs to finish,
3950    /// it keeps the keep-alive stream running instead, and the Session's slot with it, until the
3951    /// supervisor closes the Session for good.
3952    ///
3953    /// Returns `None` when this close was **not** answered here, which is every case the caller must
3954    /// handle exactly as it always has; `Some(v)` is the value
3955    /// [`close_session_with_reason`](Self::close_session_with_reason) should return. `Some` currently
3956    /// always carries `true` — a drain is only ever started for a Session that was found — but the
3957    /// value is returned rather than assumed so that the two outcomes stay distinguishable if a
3958    /// future refusal path wants to report one.
3959    ///
3960    /// The order of the checks is load-bearing:
3961    ///
3962    /// 1. the configuration, so that with `drain_after_close` (or `fill.enabled`) off nothing below runs and every
3963    ///    close path is byte-for-byte what it was;
3964    /// 2. the slot, by `get` rather than `remove` — the lookup also refreshes the idle timer the drain lives on;
3965    /// 3. an Exit-side PIX Session, so an Entry-side or non-PIX Session falls straight through;
3966    /// 4. an already-draining Session: a `WriteClosed`/`EmptyRead` is the end-of-stream our own ingress abort produces
3967    ///    and is absorbed, while anything else — a second operator close, or a peer `SessionError` — forces the hard
3968    ///    teardown, which is the operator's way to overrule a drain;
3969    /// 5. a PIX failure already tearing the Session down, which nothing here should interfere with;
3970    /// 6. the SURB estimate, which is the one half of the predicate this layer can answer — see the comment on it for
3971    ///    why answering it here rather than leaving it all to the supervisor matters;
3972    /// 7. and only then the stream is put into drain mode, **before** the supervisor is told, so that no packet can be
3973    ///    classified as the reserve-exempt notification in the window between the supervisor deciding and the stream
3974    ///    learning.
3975    ///
3976    /// What it does *not* do is decrement `active_sessions` or remove the slot: the supervisor's own
3977    /// `Close` teardown does both, whether the drain completes or is refused a channel hop later.
3978    /// `Balancer`, `KeepAlive`, `SurbNotifyDelay`, `PixActionDriver` and the deposit observers are
3979    /// all left running; only the ingress is aborted, so the session server sees its end of the
3980    /// stream as promptly as it would have.
3981    fn begin_surb_drain(&self, id: &SessionId, reason: ClosureReason) -> Option<bool> {
3982        let fill_cfg = &self.cfg.pix_config.supervision.fill;
3983        if !(fill_cfg.enabled && fill_cfg.drain_after_close) {
3984            return None;
3985        }
3986
3987        let slot = self.sessions.get(id)?;
3988        let supervisor = slot.pix_supervisor.get()?;
3989        let fill = slot.pix_fill.get()?;
3990
3991        if fill.is_draining() {
3992            return answer_while_draining(reason);
3993        }
3994
3995        if matches!(reason, ClosureReason::PixFailure | ClosureReason::MissingDepositData) {
3996            return None;
3997        }
3998
3999        // Nothing to drain *with* is decidable here, and cheaply, unlike whether there is anything
4000        // worth draining *for* — which needs the cycle state only the supervisor holds. Asking it
4001        // anyway would defer the teardown of every PIX Session by however long the action driver
4002        // takes to reach the answer, and a driver mid-`send_ssa_request` can be seconds away from
4003        // that. So a Session whose estimate is already at or below its reserve is torn down here and
4004        // now, exactly as it always was: no rate the supervisor could plan would put a packet on the
4005        // wire against a buffer that shallow.
4006        let drainable_surbs = fill.drainable_surbs();
4007        if drainable_surbs == 0 {
4008            return None;
4009        }
4010
4011        // The check above is only a fast path: `close_session_with_reason` is reached from the
4012        // operator, the closure notifier and `handle_session_error` independently, so two closes can
4013        // both read it as false. The transition is what actually decides, and the loser is answered
4014        // as it would have been had the two been serialized rather than concurrent — which is what
4015        // keeps the supervisor from being told a Session closed twice.
4016        if !fill.enter_drain() {
4017            return answer_while_draining(reason);
4018        }
4019
4020        if !supervisor.try_send_event(SessionPixEvent::SessionClosed { drainable_surbs }) {
4021            return None;
4022        }
4023
4024        // The data path ends here regardless of what the supervisor decides: a writer parked on the
4025        // egress gate is waiting for a Session that has gone, and the reader's end-of-stream is what
4026        // the session server is waiting for.
4027        if let Some(gate) = slot.pix_egress_gate.get() {
4028            gate.poison();
4029        }
4030        slot.abort_handles.lock().abort_one(&SessionHandles::Ingress);
4031
4032        #[cfg(feature = "telemetry")]
4033        set_session_state(id, SessionLifecycleState::Closing);
4034
4035        info!(
4036            session_id = %id,
4037            ?reason,
4038            drainable_surbs,
4039            "session closed at the exit — draining its buffered SURBs into the funded cycle"
4040        );
4041        Some(true)
4042    }
4043
4044    /// [`close_session`](Self::close_session) with the reason spelled out.
4045    ///
4046    /// The reason is what the operator reads when a PIX Session stops, and the failures are hard to
4047    /// tell apart from the outside, so a caller that knows which one it is says so.
4048    ///
4049    /// `true` means the Session was found and dealt with. Since
4050    /// [`begin_surb_drain`](Self::begin_surb_drain), "dealt with" also covers "found, and now
4051    /// draining its remaining SURBs into a funded cycle" — in which case the slot is deliberately
4052    /// left in place until the supervisor's own teardown removes it.
4053    fn close_session_with_reason(&self, id: &SessionId, reason: ClosureReason) -> bool {
4054        if let Some(handled) = self.begin_surb_drain(id, reason) {
4055            return handled;
4056        }
4057
4058        if let Some(slot) = self.sessions.remove(id) {
4059            self.active_sessions.fetch_sub(1, Ordering::Relaxed);
4060            // Reconstructor state is released by `close_session` aborting the PIX action driver,
4061            // whose commitment guards retire on drop.
4062            close_session(*id, slot, reason);
4063            true
4064        } else {
4065            false
4066        }
4067    }
4068
4069    /// Updates the configuration of the SURB balancer on the given [`SessionId`].
4070    ///
4071    /// Returns an error if the Session with the given `id` does not exist, or
4072    /// if it does not use SURB balancing.
4073    pub fn update_surb_balancer_config(&self, id: &SessionId, config: SurbBalancerConfig) -> errors::Result<()> {
4074        let cfg = self
4075            .sessions
4076            .get(id)
4077            .ok_or(SessionManagerError::NonExistingSession)?
4078            .surb_mgmt;
4079
4080        // Only update the config if there already was one before
4081        if !cfg.is_disabled() {
4082            cfg.update(&config);
4083            Ok(())
4084        } else {
4085            Err(SessionManagerError::other(anyhow!("session does not use SURB balancing")).into())
4086        }
4087    }
4088
4089    /// Retrieves the configuration of SURB balancing for the given Session.
4090    ///
4091    /// Returns an error if the Session with the given `id` does not exist.
4092    pub fn get_surb_balancer_config(&self, id: &SessionId) -> errors::Result<Option<SurbBalancerConfig>> {
4093        match self.sessions.get(id) {
4094            Some(session) => Ok(Some(session.surb_mgmt.as_ref())
4095                .filter(|c| !c.is_disabled())
4096                .map(|d| d.as_config())),
4097            None => Err(SessionManagerError::NonExistingSession.into()),
4098        }
4099    }
4100
4101    /// Gets estimations produced/received and consumed SURBs by the Session.
4102    ///
4103    /// For an outgoing Session (Entry) the pair is the number of SURBs sent (by us) and used (by the Exit).
4104    /// For an incoming Session (Exit) the pair is the number of SURBs received (from Entry) and used (by us).
4105    ///
4106    /// Returns an error if the Session with the given `id` does not exist.
4107    pub fn get_surb_level_estimates(&self, id: &SessionId) -> errors::Result<(u64, u64)> {
4108        match self.sessions.get(id) {
4109            Some(session) => Ok((
4110                session
4111                    .surb_estimator
4112                    .produced
4113                    .load(std::sync::atomic::Ordering::Relaxed),
4114                session
4115                    .surb_estimator
4116                    .consumed
4117                    .load(std::sync::atomic::Ordering::Relaxed),
4118            )),
4119            None => Err(SessionManagerError::NonExistingSession.into()),
4120        }
4121    }
4122
4123    /// Forwards a PIX protocol observation to the Session's supervisor.
4124    ///
4125    /// The manager no longer interprets these. Deciding what an early-recovery signal or a failed
4126    /// share means for a Session's lifecycle needs the whole picture — which SSAs are in flight,
4127    /// what phase each is in, how much service has been consumed — and that lives in the supervisor.
4128    ///
4129    /// A `NonExistingSession` error is expected traffic rather than a fault: acknowledgements
4130    /// outlive their Session by up to the reconstructor's ack window, so events for a just-closed
4131    /// Session are routine. Callers distinguish it for exactly that reason.
4132    pub async fn dispatch_pix_event(&self, event: HoprSessionInPixEvent) -> errors::Result<()> {
4133        // Copied rather than borrowed, so the match below can move the event's payload out.
4134        let session_id = *event.pseudonym();
4135        let Some(slot) = self.sessions.get(&session_id) else {
4136            debug!(%session_id, "pix event for a session that is no longer registered");
4137            return Err(SessionManagerError::NonExistingSession.into());
4138        };
4139
4140        let Some(supervisor) = slot.pix_supervisor.get() else {
4141            // Entry side, or a Session that negotiated no PIX: nothing supervises it, so there is
4142            // nothing an observation could act on.
4143            trace!(%session_id, "pix event on a session without a supervisor");
4144            return Ok(());
4145        };
4146
4147        // Progress is the one input whose rate is set by traffic rather than by lifecycle
4148        // transitions, so it is delivered without backpressure — awaiting channel capacity here
4149        // would put the supervisor's scheduling latency on the acknowledgement path. Safe by
4150        // construction rather than by tolerance: snapshots carry absolute counters and the state
4151        // machine keeps the maximum it has seen, so a dropped one is indistinguishable from a late
4152        // one, and the next one supersedes it.
4153        if let HoprSessionInPixEvent::RecoveryProgress(progress) = event {
4154            #[cfg(feature = "telemetry")]
4155            telemetry::set_pix_recovery_progress(&session_id, progress.useful_shares, progress.target_useful_shares);
4156
4157            if !supervisor.try_send_progress(progress) {
4158                trace!(%session_id, "dropped a pix progress snapshot on a full supervisor channel");
4159            }
4160            return Ok(());
4161        }
4162
4163        let sent = match event {
4164            HoprSessionInPixEvent::SsaAlmostRecovered(ssa_id) => {
4165                supervisor.send_event(SessionPixEvent::AlmostRecovered(ssa_id)).await
4166            }
4167            HoprSessionInPixEvent::SsaRecovered(ssa_id) => {
4168                supervisor.send_event(SessionPixEvent::Recovered(ssa_id)).await
4169            }
4170            HoprSessionInPixEvent::UnverifiableShares {
4171                ssa_id,
4172                observed_total,
4173                peer,
4174            } => {
4175                supervisor
4176                    .send_event(SessionPixEvent::UnverifiableShares {
4177                        ssa_id,
4178                        observed_total,
4179                        peer,
4180                    })
4181                    .await
4182            }
4183            HoprSessionInPixEvent::RecoveryProgress(_) => unreachable!("handled above"),
4184        };
4185
4186        if sent.is_err() {
4187            error!(%session_id, "pix supervisor is no longer accepting events");
4188        }
4189
4190        Ok(())
4191    }
4192
4193    /// Marks the return path to `destination` as degraded on every Session routed there.
4194    ///
4195    /// The Session layer cannot tell a dead return path from a peer with nothing to say -- both
4196    /// simply stop consuming SURBs -- so the judgement is made where sibling paths can be compared
4197    /// and delivered here. Sessions that did not opt in ignore the mark; the rest stop trusting
4198    /// their counterparty buffer estimate for `grace`.
4199    ///
4200    /// Returns the pseudonyms of the Sessions that were marked (empty when nothing routes there).
4201    ///
4202    /// The caller uses these to advance each Session's SURB generation: the return path to
4203    /// `destination` has just been re-planned, so the fresh SURB batch must supersede the SURBs the
4204    /// counterparty still holds for the dead path.
4205    pub fn mark_return_path_degraded(
4206        &self,
4207        destination: &hopr_api::types::internal::NodeId,
4208        grace: std::time::Duration,
4209    ) -> Vec<HoprPseudonym> {
4210        self.sessions
4211            .iter()
4212            .filter(|(_, slot)| {
4213                // Only sessions that actually run a return path are affected: with `return_options:
4214                // None` the session mints no return SURBs, so the counterparty holds none to
4215                // supersede and there is nothing to mark or bump.
4216                matches!(
4217                    &slot.routing_opts,
4218                    DestinationRouting::Forward { destination: d, return_options: Some(_), .. }
4219                    if d.as_ref() == destination
4220                )
4221            })
4222            .map(|(pseudonym, slot)| {
4223                slot.surb_mgmt.mark_return_path_degraded(grace);
4224                // The session cache is keyed by pseudonym, which is exactly the SURB-generation key.
4225                *pseudonym.as_ref()
4226            })
4227            .collect()
4228    }
4229
4230    /// The main method to be called whenever data are received.
4231    ///
4232    /// It tries to recognize the message and correctly dispatches either
4233    /// the Session protocol or Start protocol messages.
4234    ///
4235    /// If the data are not recognized, they are returned as [`DispatchResult::Unrelated`].
4236    pub fn dispatch_message(
4237        &self,
4238        pseudonym: HoprPseudonym,
4239        in_data: ApplicationDataIn,
4240    ) -> errors::Result<DispatchResult> {
4241        if in_data.data.application_tag == HoprStartProtocol::START_PROTOCOL_MESSAGE_TAG {
4242            // Start-protocol traffic bypasses `session_rx`, but an Exit → Entry message still
4243            // consumed one return SURB and unlocked one PIX share. Count it on outgoing Sessions
4244            // before handing it to the protocol worker; incoming Sessions receive this traffic from
4245            // the Entry over the forward path and must not be credited.
4246            if let Some(session_slot) = self.sessions.get(&pseudonym)
4247                && matches!(&session_slot.routing_opts, DestinationRouting::Forward { .. })
4248            {
4249                session_slot
4250                    .returned_packets
4251                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4252
4253                // The only place the Exit's SURB supply can be heard from a quiet Session. The
4254                // conversion below takes `in_data.data` and drops the packet info with it, so by the
4255                // time `handle_keep_alive` runs the signals are gone -- and keep-alives are all a
4256                // Session that has stopped exchanging data still sends.
4257                session_slot
4258                    .surb_mgmt
4259                    .observe_counterparty_signals(in_data.packet_info.signals_from_sender);
4260            }
4261
4262            // This is a Start protocol message, so we send it to the handler
4263            trace!("dispatching Start protocol message");
4264            if let Some(start_protocol_tx) = self.start_protocol_tx.get() {
4265                start_protocol_tx
4266                    .try_send((pseudonym, HoprStartProtocol::try_from(in_data.data)?))
4267                    .map_err(|error| {
4268                        error!(%error, "failed to send Start protocol message to processing task");
4269                        SessionManagerError::other(error)
4270                    })?;
4271            } else {
4272                return Err(SessionManagerError::NotStarted.into());
4273            }
4274
4275            #[cfg(all(feature = "telemetry", not(test)))]
4276            METRIC_DISPATCHED_MSGS.increment_by(&["processed"], 1);
4277
4278            return Ok(DispatchResult::Processed);
4279        } else if in_data.data.application_tag == SESSION_APPLICATION_TAG {
4280            // This is traffic that belongs to one of the Sessions
4281            let session_id = pseudonym;
4282
4283            // How often a sustained inbox-full condition is allowed to log. Backpressure can
4284            // affect every packet on the hot path, so warning per drop would reproduce exactly
4285            // the ERROR spam this dispatch was rewritten to avoid — warn once per interval instead.
4286            const SESSION_INBOX_FULL_WARN_INTERVAL: usize = 256;
4287
4288            return if let Some(session_slot) = self.sessions.get(&session_id) {
4289                trace!(%session_id, "received data for a registered session");
4290
4291                // Recorded here rather than once the Session reads the packet, because the send
4292                // below can drop it for local backpressure and the reader would never see it. What
4293                // the counterparty says about its own SURB supply is the override on the organic
4294                // SURB gate, so losing it to a full inbox would leave production shut off on an
4295                // estimate the counterparty has just contradicted.
4296                if matches!(&session_slot.routing_opts, DestinationRouting::Forward { .. }) {
4297                    session_slot
4298                        .surb_mgmt
4299                        .observe_counterparty_signals(in_data.packet_info.signals_from_sender);
4300                }
4301
4302                match session_slot.session_tx.try_send(in_data) {
4303                    Ok(_) => {
4304                        #[cfg(all(feature = "telemetry", not(test)))]
4305                        METRIC_DISPATCHED_MSGS.increment_by(&["processed"], 1);
4306
4307                        Ok(DispatchResult::Processed)
4308                    }
4309                    // Benign teardown race: the slot is still registered but its data sink was
4310                    // dropped. Quiet on purpose — see [`DropReason`].
4311                    Err(crossfire::TrySendError::Disconnected(_)) => {
4312                        trace!(%session_id, "dropping data for a session whose sink has closed");
4313                        crate::counters::SESSION_INBOX_CLOSED_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4314                        #[cfg(all(feature = "telemetry", not(test)))]
4315                        METRIC_DISPATCHED_MSGS.increment_by(&["dropped_sink_closed"], 1);
4316                        Ok(DispatchResult::Dropped(DropReason::SinkClosed))
4317                    }
4318                    // Genuine backpressure: the reader is not keeping up. Rate-limited so a
4319                    // sustained overload cannot become per-packet log spam.
4320                    Err(crossfire::TrySendError::Full(_)) => {
4321                        let prev =
4322                            crate::counters::SESSION_INBOX_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4323                        if prev.is_multiple_of(SESSION_INBOX_FULL_WARN_INTERVAL) {
4324                            warn!(%session_id, total_inbox_full_drops = prev + 1, "session inbox full, dropping data (backpressure)");
4325                        }
4326                        #[cfg(all(feature = "telemetry", not(test)))]
4327                        METRIC_DISPATCHED_MSGS.increment_by(&["dropped_sink_full"], 1);
4328                        Ok(DispatchResult::Dropped(DropReason::SinkFull))
4329                    }
4330                }
4331            } else {
4332                // Fully deregistered session: packets still in flight for a torn-down session.
4333                // Quiet — see [`DropReason::Unregistered`].
4334                trace!(%session_id, "dropping data for an unregistered session");
4335                crate::counters::SESSION_UNKNOWN_DATA_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4336                #[cfg(all(feature = "telemetry", not(test)))]
4337                METRIC_DISPATCHED_MSGS.increment_by(&["dropped_unregistered"], 1);
4338                Ok(DispatchResult::Dropped(DropReason::Unregistered))
4339            };
4340        }
4341
4342        trace!(tag = %in_data.data.application_tag, "received data not associated with session protocol or any existing session");
4343
4344        crate::counters::SESSION_UNRELATED_DATA_DISPATCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4345
4346        #[cfg(all(feature = "telemetry", not(test)))]
4347        METRIC_DISPATCHED_MSGS.increment_by(&["unrelated"], 1);
4348
4349        Ok(DispatchResult::Unrelated(in_data))
4350    }
4351
4352    /// Pre-populates the sessions cache with a session slot for benchmarking.
4353    ///
4354    /// Intended for benchmarks that need a session to exist before calling
4355    /// [`SessionManager::dispatch_message`].
4356    ///
4357    /// Requires the `"benchmark"` feature (or a test build).
4358    #[cfg(any(feature = "benchmark", test))]
4359    pub fn pre_populate_session(&self, session_id: SessionId, routing_opts: DestinationRouting) {
4360        let (session_tx, _) =
4361            crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
4362        let slot = SessionSlot {
4363            session_tx,
4364            routing_opts,
4365            abort_handles: Default::default(),
4366            surb_mgmt: Arc::new(BalancerStateValues::default()),
4367            surb_estimator: Default::default(),
4368            current_ssa_state: Default::default(),
4369            pix_supervisor: Default::default(),
4370            pix_egress_gate: Default::default(),
4371            pix_fill: Default::default(),
4372            returned_packets: Default::default(),
4373            // Never PIX, so there is no reconstructor state to charge for.
4374            cycle_budget: None,
4375        };
4376        self.sessions.insert(session_id, slot);
4377        self.slot_allocated
4378            .lock()
4379            .unwrap_or_else(|e| e.into_inner())
4380            .remove(&session_id);
4381    }
4382
4383    /// Like [`pre_populate_session`](SessionManager::pre_populate_session) but also returns the
4384    /// session channel receiver so the caller can spawn a drain task.
4385    ///
4386    /// Requires the `"benchmark"` feature (or a test build).
4387    #[cfg(any(feature = "benchmark", test))]
4388    pub fn pre_populate_session_with_receiver(
4389        &self,
4390        session_id: SessionId,
4391        routing_opts: DestinationRouting,
4392    ) -> crossfire::AsyncRx<crossfire::mpsc::Array<ApplicationDataIn>> {
4393        let (session_tx, session_rx) =
4394            crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
4395        let slot = SessionSlot {
4396            session_tx,
4397            routing_opts,
4398            abort_handles: Default::default(),
4399            surb_mgmt: Arc::new(BalancerStateValues::default()),
4400            surb_estimator: Default::default(),
4401            current_ssa_state: Default::default(),
4402            pix_supervisor: Default::default(),
4403            pix_egress_gate: Default::default(),
4404            pix_fill: Default::default(),
4405            returned_packets: Default::default(),
4406            // Never PIX, so there is no reconstructor state to charge for.
4407            cycle_budget: None,
4408        };
4409        self.sessions.insert(session_id, slot);
4410        self.slot_allocated
4411            .lock()
4412            .unwrap_or_else(|e| e.into_inner())
4413            .remove(&session_id);
4414        session_rx
4415    }
4416
4417    /// Charges `params`' worth of reconstructor state against the node-wide budget.
4418    ///
4419    /// Returns `None` if the node has already committed to as much as
4420    /// [`IncomingSessionPixConfig::max_live_cycle_bytes`] allows — the caller must then refuse the
4421    /// Session, because nothing later in establishment can give the memory back.
4422    ///
4423    /// A CAS loop rather than an unconditional `fetch_add` with a rollback: an add that is
4424    /// provisionally over the ceiling is briefly visible to every concurrent initiation, and with
4425    /// enough of them arriving at once the budget would appear exhausted to Sessions that do fit.
4426    fn reserve_cycle_budget(&self, params: &PixParams, ssas_per_request: usize) -> Option<Arc<CycleBudgetReservation>> {
4427        let bytes = cycle_budget_for(params, ssas_per_request);
4428        let ceiling = self.cfg.pix_config.max_live_cycle_bytes;
4429
4430        self.live_cycle_bytes
4431            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |held| {
4432                held.checked_add(bytes).filter(|total| *total <= ceiling)
4433            })
4434            .ok()
4435            .map(|_| {
4436                // Reports only this reservation's own bytes, not an outstanding total. Initiations
4437                // are processed concurrently, so any total computed here can be published out of
4438                // order with another reservation's; the gauge adds instead, which commutes.
4439                #[cfg(feature = "telemetry")]
4440                crate::telemetry::pix::record_cycle_bytes_reserved(bytes);
4441
4442                Arc::new(CycleBudgetReservation {
4443                    bytes,
4444                    outstanding: self.live_cycle_bytes.clone(),
4445                    released: std::sync::atomic::AtomicBool::new(false),
4446                })
4447            })
4448    }
4449
4450    /// Asks the session server on what terms this Session may be admitted.
4451    ///
4452    /// Fails closed. A refusal, a server that does not answer in time, a full request channel and a
4453    /// dropped reply all end the Session here rather than falling back to the node's own terms:
4454    /// falling back would mean an overloaded or wedged server silently disables every rule the
4455    /// operator wrote, and the peer would be served on terms nobody chose.
4456    ///
4457    /// The two failure kinds are reported differently because they call for different responses. A
4458    /// server that answered `Err` has decided, and re-asking will not change the answer, so the peer
4459    /// is told [`StartErrorReason::TargetNotAdmitted`]. Everything else is this node being unable to
4460    /// ask right now, which is transient, so the peer is told [`StartErrorReason::Busy`] and may
4461    /// retry.
4462    ///
4463    /// A node with no session server has nobody to ask, and admits on its own terms.
4464    async fn request_admission(
4465        &self,
4466        session_id: SessionId,
4467        req: &StartInitiation<SessionTarget, HoprSessionCapabilities>,
4468        offered: Option<PixParams>,
4469    ) -> Result<SessionAdmissionDecision, StartErrorReason> {
4470        let Some(admission_tx) = self.admission_tx.get() else {
4471            return Ok(SessionAdmissionDecision::default());
4472        };
4473
4474        let (reply_tx, reply_rx) = oneshot::channel();
4475        // The capability bits travel as the peer sent them; the offer travels decoded, since the
4476        // wire encoding is this crate's to read and the server has no business unpacking it.
4477        let request = SessionAdmissionRequest::new(session_id, req.target.clone(), req.capabilities.0.bits());
4478        let request = match offered {
4479            Some(params) => request.with_offer(OfferedIncentivization::new(
4480                params.polys_per_ssa(),
4481                params.shares_per_poly(),
4482                params.surplus_shares(),
4483                pix_params_to_quota(&params),
4484            )),
4485            None => request,
4486        };
4487
4488        // `try_send` rather than an awaited send: waiting for room in the queue would hold this
4489        // initiation past the peer's own timeout, and the queue being full is exactly the condition
4490        // the bound exists to report.
4491        if let Err(error) = admission_tx.lock().try_send((request, reply_tx)) {
4492            warn!(
4493                %session_id,
4494                %error,
4495                "cannot ask the session server to admit this session; refusing it as busy"
4496            );
4497            return Err(StartErrorReason::Busy);
4498        }
4499
4500        match reply_rx
4501            .timeout(futures_time::time::Duration::from(self.cfg.session_admission_timeout))
4502            .await
4503        {
4504            Ok(Ok(Ok(decision))) => Ok(decision),
4505            Ok(Ok(Err(error))) => {
4506                debug!(%session_id, %error, "the session server refused this session's target");
4507                Err(StartErrorReason::TargetNotAdmitted)
4508            }
4509            // The server dropped the reply without answering, which is a bug in it rather than a
4510            // decision, so it is reported as transient like the timeout it resembles.
4511            Ok(Err(_)) => {
4512                error!(
4513                    %session_id,
4514                    "the session server dropped an admission request without answering it"
4515                );
4516                Err(StartErrorReason::Busy)
4517            }
4518            Err(_) => {
4519                warn!(
4520                    %session_id,
4521                    timeout = ?self.cfg.session_admission_timeout,
4522                    "the session server did not decide in time; refusing the session as busy"
4523                );
4524                Err(StartErrorReason::Busy)
4525            }
4526        }
4527    }
4528
4529    /// Checks the offer against the terms this Session was admitted on.
4530    ///
4531    /// Judged against `policy` — the node's configuration as narrowed by the session server's
4532    /// decision for this Session — rather than against the node's configuration directly, which is
4533    /// what makes the accepted quota a property of the target as well as of the node.
4534    ///
4535    /// `offered` is what [`decode_pix_offer`] read, so this is only the
4536    /// policy question; the wire has already been judged.
4537    ///
4538    /// Returns the validated parameters and selected SSA batch size, or `None` if the offer cannot
4539    /// satisfy that policy.
4540    fn check_pix_params(
4541        &self,
4542        challenge: StartChallenge,
4543        offered: Option<PixParams>,
4544        policy: &EffectivePixAdmission,
4545    ) -> Option<(PixParams, usize)> {
4546        if let Some(params) = offered {
4547            let quota_per_ssa = pix_params_to_quota(&params);
4548            let ssas_per_request = policy.ssa_batch_size_for_quota(quota_per_ssa);
4549            let accepted_quota = ssas_per_request.and_then(|batch_size| quota_per_ssa.checked_mul(batch_size as u64));
4550            debug!(
4551                challenge,
4552                %params,
4553                acceptable_range = ?policy.quota_range,
4554                node_range = ?self.cfg.pix_config.quota_range,
4555                dynamic_batches = policy.allow_dynamic_ssa_batches,
4556                configured_max_ssas_per_request = policy.ssas_per_request,
4557                selected_ssas_per_request = ?ssas_per_request,
4558                offered_quota_mb_per_ssa = quota_per_ssa as f64 / (1024.0 * 1024.0),
4559                accepted_quota_mb = ?accepted_quota.map(|quota| quota as f64 / (1024.0 * 1024.0)),
4560                "client offered PIX SSA quota"
4561            );
4562
4563            // A window narrowed to nothing means the session server's rule and this node's envelope
4564            // do not overlap, which is a misconfiguration rather than a peer offering badly: no
4565            // offer whatsoever could have been accepted, so it is worth saying out loud once per
4566            // Session rather than leaving it to look like a rejected client.
4567            if ssas_per_request.is_none() && policy.quota_range.is_empty() {
4568                warn!(
4569                    challenge,
4570                    admitted_range = ?policy.quota_range,
4571                    node_range = ?self.cfg.pix_config.quota_range,
4572                    "the admitted quota range for this target is empty, so no offer can be accepted"
4573                );
4574            }
4575
4576            ssas_per_request.map(|batch_size| (params, batch_size))
4577        } else if policy.enforce_pix {
4578            // Client didn't offer PIX, but PIX is enforced
4579            None
4580        } else {
4581            // Client didn't offer PIX, and PIX is not enforced, so set default values
4582            // which are not going to be used.
4583            Some((DEFAULT_PIX_PARAMS, 1))
4584        }
4585    }
4586
4587    #[tracing::instrument(level = "debug", skip(self, session_req))]
4588    async fn handle_incoming_session_initiation(
4589        &self,
4590        pseudonym: HoprPseudonym,
4591        session_req: StartInitiation<SessionTarget, HoprSessionCapabilities>,
4592    ) -> errors::Result<()> {
4593        trace!(challenge = session_req.challenge, "received session initiation request");
4594
4595        debug!("got new session request, searching for a free session slot");
4596
4597        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
4598
4599        // Reply routing uses SURBs only with the pseudonym of this Session's ID
4600        let reply_routing = DestinationRouting::Return(pseudonym.into());
4601
4602        // Reject UsePIX if this node is not configured with a PixToolbox
4603        // (e.g. relay nodes that do not participate in PIX processing).
4604        if self.pix_toolbox.get().is_none() && session_req.capabilities.0.contains(Capability::UsePIX) {
4605            error!(
4606                challenge = session_req.challenge,
4607                "client offered PIX but this node has no PIX support installed"
4608            );
4609            let data = HoprStartProtocol::SessionError(StartErrorType {
4610                identifier: ErrorIdentifier::Challenge(session_req.challenge),
4611                reason: StartErrorReason::UnacceptablePixParams,
4612            });
4613            // Counted before the send, not after: the refusal has already happened, and
4614            // `send_via_msg_sender` can fail to encode, fail to send, or time out. Counting
4615            // afterwards would drop exactly the refusals that a struggling node makes most.
4616            PixAdmissionRejection::NoPixSupport.record_if_pix(session_req.capabilities.0);
4617            send_via_msg_sender(
4618                &mut msg_sender,
4619                reply_routing,
4620                data,
4621                "session error due to missing PIX support",
4622            )
4623            .await?;
4624            return Ok(());
4625        }
4626
4627        // Ask whoever runs the session server on what terms this target may be served, before
4628        // anything is allocated for the Session. This is the only place the target can influence
4629        // admission: the transport cannot read a sealed target, and by the time the server sees the
4630        // established Session the terms have already been fixed.
4631        // Read the offer before anyone is asked about it: an offer this node cannot read, or whose
4632        // curve it does not share, is refused without a round trip to the session server, and the
4633        // server is told what was offered rather than having to unpack the wire itself.
4634        let offered = match decode_pix_offer(&session_req) {
4635            Ok(offered) => offered,
4636            Err(reason) => {
4637                error!(
4638                    challenge = session_req.challenge,
4639                    "client offered unacceptable PIX parameters"
4640                );
4641                let data = HoprStartProtocol::SessionError(StartErrorType {
4642                    identifier: ErrorIdentifier::Challenge(session_req.challenge),
4643                    reason,
4644                });
4645                PixAdmissionRejection::UnacceptableParams.record_if_pix(session_req.capabilities.0);
4646                send_via_msg_sender(
4647                    &mut msg_sender,
4648                    reply_routing,
4649                    data,
4650                    "session error message due to unacceptable PIX parameters",
4651                )
4652                .await?;
4653
4654                // Left after the send, unlike the line above: this one counts errors *sent*, so a
4655                // send that never happened is correctly not one of them.
4656                #[cfg(all(feature = "telemetry", not(test)))]
4657                METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4658                return Ok(());
4659            }
4660        };
4661
4662        let session_id: SessionId = pseudonym;
4663        let admission = match self.request_admission(session_id, &session_req, offered).await {
4664            Ok(decision) => decision,
4665            Err(reason) => {
4666                let data = HoprStartProtocol::SessionError(StartErrorType {
4667                    identifier: ErrorIdentifier::Challenge(session_req.challenge),
4668                    reason,
4669                });
4670                // `request_admission` distinguishes these two on purpose — a refused target will not
4671                // be served however the request is phrased, while `Busy` is this node being unable
4672                // to ask right now and is worth retrying — so the aggregate keeps them apart too.
4673                match reason {
4674                    StartErrorReason::TargetNotAdmitted => PixAdmissionRejection::TargetPolicy,
4675                    _ => PixAdmissionRejection::Busy,
4676                }
4677                .record_if_pix(session_req.capabilities.0);
4678                send_via_msg_sender(
4679                    &mut msg_sender,
4680                    reply_routing,
4681                    data,
4682                    "session error due to a refused admission",
4683                )
4684                .await?;
4685
4686                #[cfg(all(feature = "telemetry", not(test)))]
4687                METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4688                return Ok(());
4689            }
4690        };
4691        let pix_policy = self.cfg.pix_config.effective_for(&admission);
4692
4693        // Verify the offer against the terms this Session was admitted on.
4694        let Some((client_params, ssas_per_request)) =
4695            self.check_pix_params(session_req.challenge, offered, &pix_policy)
4696        else {
4697            error!(
4698                challenge = session_req.challenge,
4699                "client offered unacceptable PIX parameters"
4700            );
4701
4702            // Notify the sender that the session could not be established
4703            let reason = StartErrorReason::UnacceptablePixParams;
4704            let data = HoprStartProtocol::SessionError(StartErrorType {
4705                identifier: ErrorIdentifier::Challenge(session_req.challenge),
4706                reason,
4707            });
4708            PixAdmissionRejection::UnacceptableParams.record_if_pix(session_req.capabilities.0);
4709            send_via_msg_sender(
4710                &mut msg_sender,
4711                reply_routing,
4712                data,
4713                "session error message due to unacceptable PIX parameters",
4714            )
4715            .await?;
4716
4717            #[cfg(all(feature = "telemetry", not(test)))]
4718            METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4719            return Ok(());
4720        };
4721
4722        info!(params = %client_params, ssas_per_request, "client offered acceptable PIX parameters");
4723
4724        // Charge this Session's reconstructor state against the node-wide budget before anything is
4725        // allocated for it. Only a PIX Session holds cycle state, so only a PIX Session is charged —
4726        // `check_pix_params` hands back nominal parameters for a peer that offered none, and
4727        // reserving on those would bill Sessions that never reconstruct anything.
4728        //
4729        // Refused here rather than at the successor request, which is the other place the ceiling
4730        // could be applied: by then the Entry has funded a cycle and refusing costs it that deposit,
4731        // whereas a Session refused now is one the peer can retry elsewhere at no charge.
4732        let cycle_budget = if session_req.capabilities.0.contains(Capability::UsePIX) {
4733            let Some(reservation) = self.reserve_cycle_budget(&client_params, ssas_per_request) else {
4734                warn!(
4735                    challenge = session_req.challenge,
4736                    requested = cycle_budget_for(&client_params, ssas_per_request),
4737                    outstanding = self.live_cycle_bytes.load(Ordering::Relaxed),
4738                    ceiling = self.cfg.pix_config.max_live_cycle_bytes,
4739                    "refusing a PIX session: the node's live reconstructor-cycle budget is exhausted"
4740                );
4741
4742                let reason = StartErrorReason::NoSlotsAvailable;
4743                let data = HoprStartProtocol::SessionError(StartErrorType {
4744                    identifier: ErrorIdentifier::Challenge(session_req.challenge),
4745                    reason,
4746                });
4747                // The refusal this whole aggregate exists to separate from the one below. Both are
4748                // `NoSlotsAvailable` on the wire, but this one says the node is healthy and full of
4749                // reconstructor state, and the other says it is at its Session limit.
4750                PixAdmissionRejection::LiveCycleCapacity.record_if_pix(session_req.capabilities.0);
4751                send_via_msg_sender(
4752                    &mut msg_sender,
4753                    reply_routing,
4754                    data,
4755                    "session error message due to an exhausted live-cycle budget",
4756                )
4757                .await?;
4758
4759                #[cfg(all(feature = "telemetry", not(test)))]
4760                METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4761                return Ok(());
4762            };
4763            Some(reservation)
4764        } else {
4765            None
4766        };
4767
4768        let (new_session_notifier, close_session_notifier) = self
4769            .session_notifiers
4770            .get()
4771            .cloned()
4772            .ok_or(SessionManagerError::NotStarted)?;
4773
4774        // Use constant application tag for all sessions
4775        self.sessions.run_pending_tasks();
4776
4777        let (session_tx, session_rx) =
4778            crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
4779        let (session_rx, session_rx_ah) = hopr_utils::runtime::DropAbortable::new(session_rx.into_stream());
4780
4781        let slot = SessionSlot {
4782            session_tx,
4783            routing_opts: reply_routing.clone(),
4784            abort_handles: Default::default(),
4785            surb_mgmt: Default::default(),
4786            surb_estimator: Default::default(),
4787            current_ssa_state: Default::default(),
4788            pix_supervisor: Default::default(),
4789            pix_egress_gate: Default::default(),
4790            pix_fill: Default::default(),
4791            returned_packets: Default::default(),
4792            cycle_budget,
4793        };
4794        slot.abort_handles.lock().insert(SessionHandles::Ingress, session_rx_ah);
4795
4796        // Insert the slot and get a guard. Any failure from here on rolls the slot
4797        // back, otherwise it would block this pseudonym until idle eviction. The atomic
4798        // insert (inside the helper) also prevents a TOCTOU race, so only one concurrent
4799        // request can claim the slot for a given pseudonym.
4800        let Some(mut slot_guard) = self.allocate_session_slot(session_id, slot.clone()) else {
4801            // No slots available for this pseudonym
4802            error!("no slots available for this pseudonym");
4803            let reason = StartErrorReason::NoSlotsAvailable;
4804            let data = HoprStartProtocol::SessionError(StartErrorType {
4805                identifier: ErrorIdentifier::Challenge(session_req.challenge),
4806                reason,
4807            });
4808
4809            PixAdmissionRejection::NoSessionSlot.record_if_pix(session_req.capabilities.0);
4810            send_via_msg_sender(
4811                &mut msg_sender,
4812                reply_routing.clone(),
4813                data,
4814                "session error message due to lack of slots",
4815            )
4816            .await?;
4817
4818            #[cfg(all(feature = "telemetry", not(test)))]
4819            METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4820
4821            return Ok(());
4822        };
4823
4824        debug!(?session_req, "assigned a new session");
4825
4826        // Stand the supervisor up before the `HoprSession` is constructed, so the egress adapters
4827        // below see a populated gate. Installing it afterwards would leave a window in which packets
4828        // are served ungated — small, but exactly the window an unfunded Entry would aim for.
4829        //
4830        // Its first `RequestSsa` action is deliberately *not* carried out here: the action driver
4831        // spawned after publication does it, which is what keeps `SessionEstablished` ahead of
4832        // `SsaRequest` on the wire.
4833        let pix: Option<ActionRx> = if self.pix_toolbox.get().is_some()
4834            && session_req.capabilities.0.contains(Capability::UsePIX)
4835        {
4836            // We use the same dimensions the client offered.
4837            slot.current_ssa_state
4838                .set(SessionSsaState::new(client_params))
4839                .map_err(|_| SessionManagerError::other(anyhow::anyhow!("session pix state must be uninitialized")))?;
4840
4841            let supervisor_config = SupervisorConfig {
4842                ssas_per_request,
4843                ..self.cfg.pix_config.supervisor_config()
4844            };
4845            let (handle, action_rx) =
4846                spawn_supervisor_worker(supervisor_config, client_params, session_id, std::time::Instant::now());
4847
4848            let _ = slot.pix_egress_gate.set(handle.gate.clone());
4849            let _ = slot.pix_supervisor.set(handle);
4850            Some(action_rx)
4851        } else {
4852            None
4853        };
4854
4855        // The SURB buffer target the Entry asked for, resolved once for both branches below.
4856        //
4857        // The Session request carries a "hint" as additional data telling what the Session initiator
4858        // has configured as its target buffer size in the Balancer. The lower 32 bits of the options
4859        // word contain the SURB target; its upper 32 bits carry PIX parameters and must be masked out.
4860        //
4861        // Resolved even on the `NoRateControl` branch, which runs no balancer and never announces a
4862        // target of its own: the Exit's keep-alive stream derives its fill SURB reserve from this
4863        // figure, and on that branch the fallback below — the same one the balancer would have used —
4864        // is the only estimate of the buffer there is.
4865        let announced_surb_target = {
4866            let surb_target = initiation_options(session_req.additional_data) as u32;
4867            if surb_target > 0 {
4868                (surb_target as u64).min(self.cfg.maximum_surb_buffer_size as u64)
4869            } else {
4870                self.cfg.initial_return_session_egress_rate as u64
4871                    * self
4872                        .cfg
4873                        .minimum_surb_buffer_duration
4874                        .max(MIN_SURB_BUFFER_DURATION)
4875                        .as_secs()
4876            }
4877        };
4878
4879        let closure_notifier = Box::new(move |session_id: SessionId, reason: ClosureReason| {
4880            if let Err(error) = close_session_notifier.try_send((session_id, reason)) {
4881                error!(%session_id, %error, %reason, "failed to notify session closure");
4882            }
4883        });
4884
4885        let session = if !session_req.capabilities.0.contains(Capability::NoRateControl) {
4886            // Because of SURB scarcity, control the egress rate of incoming sessions
4887            let egress_rate_control =
4888                RateController::new(self.cfg.initial_return_session_egress_rate, Duration::from_secs(1));
4889
4890            let target_surb_buffer_size = announced_surb_target;
4891
4892            let surb_estimator_tx = slot.surb_estimator.clone();
4893            let surb_estimator_rx = slot.surb_estimator.clone();
4894            // Resolved once, here, rather than per packet: the gate was installed before this point,
4895            // so the egress path never has to look inside the `OnceLock` again.
4896            let egress_gate = slot.pix_egress_gate.get().cloned();
4897            let session = HoprSession::new(
4898                session_id,
4899                reply_routing.clone(),
4900                session_config(&self.cfg, session_req.capabilities.into()),
4901                (
4902                    // Sent packets = SURB consumption estimate
4903                    msg_sender
4904                        .clone()
4905                        .sink_map_err(std::io::Error::other)
4906                        .with(move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
4907                            // Each outgoing packet consumes one SURB
4908                            surb_estimator_tx
4909                                .consumed
4910                                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4911                            #[cfg(feature = "telemetry")]
4912                            telemetry::record_session_surb_consumed(&session_id, 1);
4913                            acquire_egress_permit(egress_gate.clone(), routing, data)
4914                        })
4915                        .rate_limit_with_controller(&egress_rate_control)
4916                        .buffer((2 * target_surb_buffer_size) as usize),
4917                    // Received packets = SURB retrieval estimate
4918                    session_rx.inspect(move |data| {
4919                        // SURBs saved into the store, and older ones the full store evicted to make room.
4920                        let produced = data.num_surbs_with_msg() as u64;
4921                        let evicted = data.packet_info.num_evicted_surbs as u64;
4922                        account_incoming_surbs(&surb_estimator_rx, produced, evicted);
4923                        #[cfg(feature = "telemetry")]
4924                        telemetry::record_session_surb_produced(&session_id, produced);
4925                    }),
4926                ),
4927                Some(closure_notifier),
4928            )?;
4929
4930            // The SURB balancer will start intervening by rate-limiting the
4931            // egress of the Session, once the estimated number of SURBs drops below
4932            // the target defined here. Otherwise, the maximum egress is allowed.
4933            let balancer_config = SurbBalancerConfig {
4934                target_surb_buffer_size,
4935                // At maximum egress, the SURB buffer drains in `minimum_surb_buffer_duration` seconds
4936                max_surbs_per_sec: target_surb_buffer_size / self.cfg.minimum_surb_buffer_duration.as_secs(),
4937                // No SURB decay at the Exit, since we know almost exactly how many SURBs
4938                // were received
4939                surb_decay: None,
4940                sustain_on_return_path_loss: false,
4941            };
4942
4943            slot.surb_mgmt.update(&balancer_config);
4944            slot.surb_mgmt
4945                .set_counterparty_buffer_capacity(self.cfg.maximum_surb_buffer_size as u64);
4946
4947            // Spawn the SURB balancer only once we know we have registered the
4948            // abort handle with the pre-allocated Session slot
4949            debug!(%session_id, ?balancer_config ,"spawning exit SURB balancer");
4950            let balancer = SurbBalancer::new(
4951                session_id,
4952                SimpleBalancerController::default(),
4953                slot.surb_estimator.clone(),
4954                SurbControllerWithCorrection(egress_rate_control, 1), // 1 SURB per egress packet
4955                slot.surb_mgmt.clone(),
4956            );
4957
4958            // Assign the SURB balancer and abort handles to the already allocated Session slot
4959            let (_, balancer_abort_handle) = balancer.start_control_loop(self.cfg.balancer_sampling_interval);
4960            slot.abort_handles
4961                .lock()
4962                .insert(SessionHandles::Balancer, balancer_abort_handle);
4963
4964            // Reports the SURB buffer level towards the Entry, and — on a PIX Session — carries the
4965            // Exit's own fill.
4966            self.spawn_exit_keep_alive(
4967                session_id,
4968                &slot,
4969                msg_sender.clone(),
4970                pix.is_some(),
4971                announced_surb_target,
4972            );
4973
4974            session
4975        } else {
4976            // `NoRateControl`: no SURB balancer, but the PIX gate still applies. A Session that
4977            // opts out of rate control is exactly the one that could drain the most service before
4978            // funding, so leaving this path ungated would make the predeposit budget optional.
4979            let egress_gate = slot.pix_egress_gate.get().cloned();
4980            let surb_estimator_tx = slot.surb_estimator.clone();
4981            let surb_estimator_rx = slot.surb_estimator.clone();
4982            let session = HoprSession::new(
4983                session_id,
4984                reply_routing.clone(),
4985                session_config(&self.cfg, session_req.capabilities.into()),
4986                (
4987                    msg_sender.clone().sink_map_err(std::io::Error::other).with(
4988                        move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
4989                            // Counted here too, though nothing on this branch balances anything. The
4990                            // estimate is what the fill reserve is measured against, and a reserve
4991                            // measured against a counter that only ever counts in one direction is a
4992                            // reserve that either never binds or never releases. `NoRateControl` is
4993                            // also the branch that can drain SURBs fastest, so it is the one where
4994                            // that matters most.
4995                            surb_estimator_tx
4996                                .consumed
4997                                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4998                            #[cfg(feature = "telemetry")]
4999                            telemetry::record_session_surb_consumed(&session_id, 1);
5000                            acquire_egress_permit(egress_gate.clone(), routing, data)
5001                        },
5002                    ),
5003                    session_rx.inspect(move |data| {
5004                        // SURBs saved into the store, and older ones the full store evicted to make room.
5005                        let produced = data.num_surbs_with_msg() as u64;
5006                        let evicted = data.packet_info.num_evicted_surbs as u64;
5007                        account_incoming_surbs(&surb_estimator_rx, produced, evicted);
5008                        #[cfg(feature = "telemetry")]
5009                        telemetry::record_session_surb_produced(&session_id, produced);
5010                    }),
5011                ),
5012                Some(closure_notifier),
5013            )?;
5014
5015            self.spawn_exit_keep_alive(
5016                session_id,
5017                &slot,
5018                msg_sender.clone(),
5019                pix.is_some(),
5020                announced_surb_target,
5021            );
5022
5023            session
5024        };
5025
5026        // Extract useful information about the session from the Start protocol message
5027        let incoming_session = IncomingSession {
5028            id: session_id,
5029            session,
5030            target: session_req.target,
5031        };
5032
5033        // Notify that a new incoming session has been created. Lock the sink and send
5034        // directly into it, so no extra forwarding task between channels is needed.
5035        match async {
5036            let mut guard = new_session_notifier.lock().await;
5037            guard.send(incoming_session).await
5038        }
5039        .timeout(futures_time::time::Duration::from(EXTERNAL_SEND_TIMEOUT))
5040        .await
5041        {
5042            Err(_) => {
5043                error!(%session_id, "timeout to notify about new incoming session");
5044                return Err(TransportSessionError::Timeout);
5045            }
5046            Ok(Err(error)) => {
5047                error!(%session_id, %error, "failed to notify about new incoming session");
5048                return Err(SessionManagerError::other(error).into());
5049            }
5050            _ => {}
5051        };
5052
5053        trace!(?session_id, "session notification sent");
5054
5055        // Notify the sender that the session has been established.
5056        // Set our peer ID in the session ID sent back to them.
5057        let data = HoprStartProtocol::SessionEstablished(StartEstablished {
5058            orig_challenge: session_req.challenge,
5059            session_id,
5060        });
5061
5062        send_via_msg_sender(
5063            &mut msg_sender,
5064            reply_routing.clone(),
5065            data,
5066            "session establishment message",
5067        )
5068        .await?;
5069
5070        #[cfg(feature = "telemetry")]
5071        initialize_session_telemetry(
5072            session_id,
5073            &self.cfg,
5074            session_req.capabilities.0,
5075            Some(&slot.surb_estimator),
5076            Some(&slot.surb_mgmt),
5077        );
5078
5079        // The session is published, so the supervisor's actions can now be carried out. The first of
5080        // them is the initial `RequestSsa`, which is why this runs after `SessionEstablished` has
5081        // gone out: the Entry must see the two in that order.
5082        if let Some(action_rx) = pix {
5083            let driver = self.spawn_pix_action_driver(session_id, &slot, action_rx, reply_routing.clone());
5084            slot.abort_handles
5085                .lock()
5086                .insert(SessionHandles::PixActionDriver, driver);
5087        }
5088
5089        #[cfg(all(feature = "telemetry", not(test)))]
5090        METRIC_NUM_ESTABLISHED_SESSIONS.increment();
5091
5092        slot_guard.commit();
5093
5094        Ok(())
5095    }
5096
5097    #[tracing::instrument(level = "debug", skip(self, est))]
5098    async fn handle_session_established(&self, est: StartEstablished<SessionId>) -> errors::Result<()> {
5099        debug!(
5100            session_id = ?est.session_id,
5101            "received session establishment confirmation"
5102        );
5103        let challenge = est.orig_challenge;
5104        let session_id = est.session_id;
5105
5106        if let Some(tx_est) = self.session_initiations.remove(&challenge) {
5107            if let Err(error) = tx_est.try_send(Ok(est)) {
5108                error!(%challenge, %session_id, %error, "failed to send session establishment confirmation");
5109                return Err(SessionManagerError::other(error).into());
5110            }
5111            debug!(?session_id, challenge, "session establishment complete");
5112        } else {
5113            error!(%session_id, challenge, "unknown session establishment attempt or expired");
5114        }
5115        Ok(())
5116    }
5117
5118    #[tracing::instrument(level = "debug", skip(self))]
5119    async fn handle_session_error(&self, error_type: StartErrorType<SessionId>) -> errors::Result<()> {
5120        let reason = error_type.reason;
5121        match error_type.identifier {
5122            ErrorIdentifier::Challenge(challenge) => {
5123                trace!(%challenge, error = ?error_type.reason, "session initiation error received");
5124                if let Some(tx_est) = self.session_initiations.remove(&challenge) {
5125                    if let Err(error) = tx_est.try_send(Err(error_type)) {
5126                        error!(%error, "could not send session error message");
5127                        return Err(SessionManagerError::other(error).into());
5128                    }
5129                    error!(%challenge, "session establishment error received");
5130                } else {
5131                    error!(
5132                        %challenge,
5133                        "session establishment attempt expired before error could be delivered"
5134                    );
5135                }
5136            }
5137            ErrorIdentifier::SessionId(session_id) => {
5138                error!(
5139                    %session_id, %reason,
5140                    "received post-establishment session error — closing session"
5141                );
5142                // Best-effort close; the session may have already been removed.
5143                self.close_session(&session_id);
5144            }
5145        }
5146
5147        #[cfg(all(feature = "telemetry", not(test)))]
5148        METRIC_RECEIVED_SESSION_ERRS.increment(&[&reason.to_string()]);
5149
5150        Ok(())
5151    }
5152
5153    #[tracing::instrument(level = "trace", skip(self, msg))]
5154    async fn handle_keep_alive(&self, msg: KeepAliveMessage<SessionId>) -> errors::Result<()> {
5155        let session_id = msg.session_id;
5156        if let Some(session_slot) = self.sessions.get(&session_id) {
5157            trace!(?session_id, "received keep-alive message");
5158            match &session_slot.routing_opts {
5159                // Session is outgoing - keep-alive was received from the Exit
5160                DestinationRouting::Forward { .. } => {
5161                    if msg.flags.contains(KeepAliveFlag::BalancerState) && !session_slot.surb_mgmt.is_disabled() {
5162                        // Record the reported level and let it infer the counterparty's real store
5163                        // capacity from a plateau. Every report is fed in, including one that repeats
5164                        // the last value -- a level that stops rising while we are still minting is
5165                        // exactly the plateau signal, so this must not dedup equal reports.
5166                        let changed = session_slot.surb_mgmt.buffer_level() != msg.additional_data;
5167                        session_slot.surb_mgmt.observe_reported_level(msg.additional_data);
5168                        if changed {
5169                            debug!(%session_id, surb_level = msg.additional_data, "keep-alive updated SURB buffer size from the Exit");
5170                        }
5171                    }
5172
5173                    // Increase the number of consumed SURBs in the estimator
5174                    session_slot
5175                        .surb_estimator
5176                        .consumed
5177                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5178                    #[cfg(feature = "telemetry")]
5179                    telemetry::record_session_surb_consumed(&session_id, 1);
5180                }
5181                // Session is incoming - keep-alive was received from the Entry
5182                DestinationRouting::Return(_) => {
5183                    // Allow updating SURB balancer target based on the received Keep-Alive message
5184                    if msg.flags.contains(KeepAliveFlag::BalancerTarget)
5185                        && msg.additional_data > 0
5186                        && !session_slot.surb_mgmt.is_disabled()
5187                        && session_slot.surb_mgmt.controller_bounds().target() != msg.additional_data
5188                    {
5189                        // Update the target buffer size as sent to us from the Entry
5190                        session_slot
5191                            .surb_mgmt
5192                            .target_surb_buffer_size
5193                            .store(msg.additional_data, std::sync::atomic::Ordering::Relaxed);
5194                        // Update maximum SURBs per second based on the new target
5195                        session_slot.surb_mgmt.max_surbs_per_sec.store(
5196                            msg.additional_data / self.cfg.minimum_surb_buffer_duration.as_secs(),
5197                            std::sync::atomic::Ordering::Relaxed,
5198                        );
5199                        debug!(%session_id, target_surb_buffer_size = msg.additional_data, "keep-alive updated SURB balancer target buffer size from the Entry");
5200                    }
5201
5202                    // Increase the number of received SURBs in the estimator.
5203                    let produced = msg.max_surbs()? as u64;
5204                    session_slot
5205                        .surb_estimator
5206                        .produced
5207                        .fetch_add(produced, std::sync::atomic::Ordering::Relaxed);
5208                    #[cfg(feature = "telemetry")]
5209                    telemetry::record_session_surb_produced(&session_id, produced);
5210                }
5211            }
5212        } else {
5213            debug!(%session_id, "received keep-alive request for an unknown session");
5214        }
5215
5216        Ok(())
5217    }
5218
5219    /// Handled by the Exit, when Entry replies with PIX commitment
5220    #[tracing::instrument(level = "debug", skip(self, msg))]
5221    async fn handle_ssa_commit(
5222        &self,
5223        pseudonym: HoprPseudonym,
5224        msg: SsaClientCommitmentMessage<SessionId, HoprPixGroupElement, HoprPixCommitmentProof>,
5225    ) -> errors::Result<()> {
5226        let Some(pix_toolbox) = self.pix_toolbox.get().cloned() else {
5227            return Err(SessionManagerError::UnsupportedMessage.into());
5228        };
5229
5230        let session_id = msg.session_id;
5231
5232        if pseudonym != session_id {
5233            error!(%pseudonym, %msg.session_id, "received SSA client commitment for a different session");
5234            return Err(SessionManagerError::NonExistingSession.into());
5235        }
5236
5237        let Some(session_slot) = self.sessions.get(&session_id) else {
5238            return Err(SessionManagerError::NonExistingSession.into());
5239        };
5240
5241        // See if we haven't received an SSA commitment for a Session that we did not register as PIX-capable
5242        let Some(ssa_state) = session_slot.current_ssa_state.get() else {
5243            return Err(SessionManagerError::Other(anyhow::anyhow!("no SSA state for session {session_id}")).into());
5244        };
5245        let quota_per_ssa = ssa_state.quota_per_ssa();
5246
5247        let ssa_id = SsaId::new(pseudonym, msg.ssa_index);
5248
5249        // Decode the accompanying proof of knowledge, if the message carries one. A malformed proof
5250        // is rejected here rather than being passed on as absent, so that it cannot be mistaken for
5251        // a peer that simply did not send one.
5252        let commitment_proof = msg
5253            .commitment_proof
5254            .map(|proof| proof.try_into_pix_proof())
5255            .transpose()
5256            .map_err(SessionManagerError::other)?;
5257
5258        // Insert the newly received coefficients into the SSA Reconstructor
5259        let pix_toolbox_clone = pix_toolbox.clone();
5260        let ssa_client_commitment_state = hopr_utils::parallelize::cpu::spawn_blocking(
5261            move || {
5262                pix_toolbox_clone
5263                    .share_processor
5264                    .insert_coefficient_commitments(
5265                        ssa_id,
5266                        msg.coefficient_index,
5267                        commitment_proof,
5268                        msg.coefficient_commitments.into_iter().map(|(k, v)| (k, v.0)),
5269                    )
5270                    .map_err(SessionManagerError::PixError)
5271            },
5272            "ssa commitment reconstructor",
5273        )
5274        .await
5275        .map_err(|_| {
5276            SessionManagerError::Other(anyhow::anyhow!(
5277                "failed to insert SSA coefficients into the SSA reconstructor"
5278            ))
5279        })??;
5280
5281        // A verifiable commitment is what starts the deposit clock, so tell the supervisor before
5282        // the observer below can report anything against it.
5283        //
5284        // An *incomplete* one is reported too, and per message: that is what arms the supervisor's
5285        // re-request timer and keeps pushing it out while the burst is still arriving, so it fires
5286        // only once delivery has actually stalled. It is also the only evidence that the Entry
5287        // answered at all, which is what separates a cycle worth re-asking for from one where
5288        // nothing was ever delivered and there is no scope to name.
5289        if let Some(supervisor) = session_slot.pix_supervisor.get() {
5290            let event = if ssa_client_commitment_state.is_verifiable {
5291                SessionPixEvent::CommitmentVerified(ssa_id)
5292            } else {
5293                SessionPixEvent::CommitmentProgress(ssa_id)
5294            };
5295            if supervisor.send_event(event).await.is_err() {
5296                error!(%session_id, %ssa_id, "pix supervisor is no longer accepting events");
5297            }
5298        }
5299
5300        if ssa_client_commitment_state.deposit_address_first_encountered
5301            && let Some(deposit_address) = ssa_client_commitment_state.ssa_deposit_address
5302        {
5303            // Inside the guard on purpose: every other `SsaCommit` of a cycle takes the other branch,
5304            // so allocating this before the `if` built and dropped a channel per message.
5305            let (deposit_done_tx, deposit_done_rx) =
5306                futures::channel::mpsc::channel::<(PixAddressId, hopr_api::HoprBalance)>(10);
5307            // Report deposits for as long as they arrive rather than waiting for the first one and
5308            // stopping: the pool may report more than once for a cycle, and a first message carrying
5309            // a zero balance asserts nothing, so the one that funds the cycle need not be the first.
5310            // The supervisor owns the deadline, so the observer carries none — a timeout here would
5311            // have been a second authority racing the first.
5312            let supervisor = session_slot.pix_supervisor.get().cloned();
5313            session_slot.abort_handles.lock().insert(
5314                SessionHandles::PixDepositObserver(ssa_id.ssa_index().get()),
5315                hopr_utils::spawn_as_abortable!(async move {
5316                    let Some(supervisor) = supervisor else {
5317                        // No supervisor: a Session that negotiated PIX always has one, so this is
5318                        // only reachable if it died between the check above and here.
5319                        return;
5320                    };
5321                    let mut confirmations = deposit_done_rx.filter(|(evt_id, _)| {
5322                        futures::future::ready(
5323                            evt_id.ssa_index() == ssa_id.ssa_index() && &evt_id.session_id() == ssa_id.pseudonym(),
5324                        )
5325                    });
5326                    while let Some((_, amount)) = confirmations.next().await {
5327                        info!(%session_id, %ssa_id, %amount, "ssa deposit confirmed");
5328                        if supervisor
5329                            .send_event(SessionPixEvent::DepositConfirmed { ssa_id, amount })
5330                            .await
5331                            .is_err()
5332                        {
5333                            return;
5334                        }
5335                    }
5336                    // The upper layer dropped the sender without ever confirming, so no deposit is
5337                    // coming. Saying so is better than letting the supervisor wait out its deadline.
5338                    warn!(%session_id, %ssa_id, "deposit channel closed without confirmation");
5339                    let _ = supervisor
5340                        .send_event(SessionPixEvent::DepositObserverClosed(ssa_id))
5341                        .await;
5342                }),
5343            );
5344
5345            // Notify upstream that deposit is needed
5346            pix_toolbox
5347                .pix_events
5348                .try_send(HoprSessionOutPixEvent::DepositNeeded(
5349                    AgreedSsaQuota {
5350                        ssa_id,
5351                        deposit_address,
5352                        quota_per_ssa,
5353                        // Handed back to the pool that produced it, now that the address it pays for
5354                        // is known. Consumed here: this branch is reached once per cycle.
5355                        deposit_data: ssa_state.take_sent_deposit_data(&session_id, ssa_id.ssa_index()),
5356                    },
5357                    deposit_done_tx,
5358                ))
5359                .map_err(|_| {
5360                    SessionManagerError::other(anyhow::anyhow!("failed to send pix event for needed deposit"))
5361                })?;
5362            info!(%ssa_id, %deposit_address, quota_per_ssa, "retrieved first client SSA commitment and deposit address");
5363        }
5364
5365        Ok(())
5366    }
5367
5368    /// Tells the Exit that its [`SsaServerCommitmentMessage`] was refused, and tears down this half
5369    /// of the Session.
5370    ///
5371    /// Without the notice the refusal is invisible to the Exit, and it has no way to recover from it.
5372    /// Its supervisor registered an Exit commitment per requested index and armed each cycle's
5373    /// commitment deadline the moment the request went out; it will never receive an `SsaCommit`, and
5374    /// nothing can make it re-ask, because a new `RequestSsa` only comes from share-recovery events and
5375    /// no shares exist for a cycle the Entry never committed to. So it serves the Session
5376    /// unincentivized for the whole batch-scaled `max_ssa_delivery_time` and then closes it as
5377    /// `CommitmentTimeout` — a reason that names the clock rather than the refusal, on the one node
5378    /// whose operator can act on it.
5379    ///
5380    /// Closing this half too is what stops the Entry sitting on a Session that can never make PIX
5381    /// progress: a refusal is terminal either way, since the Exit re-derives every request from state
5382    /// that cannot drift within a Session, so a later one would be refused identically. Left alone the
5383    /// slot would survive until the idle timeout.
5384    ///
5385    /// No new capability is handed to an attacker by closing on a refusal: an `SsaRequest` only reaches
5386    /// here Sphinx-authenticated and with `pseudonym == session_id`, so only the Exit can produce one —
5387    /// and the Exit can already close the Session whenever it likes.
5388    async fn refuse_ssa_request(&self, session_id: SessionId, routing: DestinationRouting) {
5389        self.notify_session_error(
5390            session_id,
5391            routing,
5392            StartErrorReason::UnacceptablePixParams,
5393            "session error due to a refused SSA request",
5394        )
5395        .await;
5396
5397        if self.close_session(&session_id) {
5398            error!(%session_id, "closed session after refusing the Exit's SSA request");
5399        }
5400    }
5401
5402    /// Handled by the Entry, when the Exit says it never received parts of a commitment this node
5403    /// already published.
5404    ///
5405    /// Re-sends the named polynomial commitments and nothing else. Emits no
5406    /// [`ReadyToDeposit`](HoprSessionOutPixEvent::ReadyToDeposit): the deposit address of a cycle is
5407    /// fixed when it is first committed, and the caller was told it then, so announcing it again
5408    /// would invite a second deposit against one quota.
5409    ///
5410    /// A failure here is reported to the caller of the Start-message dispatcher and otherwise
5411    /// dropped. It is deliberately not [`refuse_ssa_request`](Self::refuse_ssa_request), which closes
5412    /// the Session: an Exit that asks for a cycle this node cannot serve is asking for something
5413    /// already lost, and its own commitment deadline ends the Session either way — whereas closing on
5414    /// a request that merely raced our own state would throw away a Session that was about to work.
5415    ///
5416    /// Two separate bounds keep this from being a packet-amplification lever, because one request may
5417    /// name a whole cycle's polynomials and the answer to that is the entire commitment burst again.
5418    /// The generator bounds how often it answers for *one* cycle
5419    /// ([`MAX_COMMITMENT_RETRANSMISSIONS`](hopr_protocol_pix::MAX_COMMITMENT_RETRANSMISSIONS)); this
5420    /// function bounds how many cycles one request may set going. Neither stands in for the other —
5421    /// the first bounds repeats, the second bounds breadth.
5422    async fn handle_ssa_recommit(
5423        &self,
5424        pseudonym: HoprPseudonym,
5425        session_slot: &SessionSlot,
5426        msg: SsaServerCommitmentMessage<SessionId, HoprPixGroupElement, HoprPixDepositPayload>,
5427    ) -> errors::Result<()> {
5428        // The wire bounds the *runs* a request carries, not the number of cycles they are spread
5429        // over — one run of 8 bytes can name a whole cycle — so without this a single packet names
5430        // every retained cycle and is answered with a full commitment burst for each. The generator's
5431        // per-cycle attempt cap does not bound that: it bounds repeats of one cycle, not breadth
5432        // across cycles.
5433        //
5434        // The same cap as the new-SSA path, so repairing a batch can never cost more than committing
5435        // to it did, and the Exit's own requests name a single cycle. Dropped rather than refused
5436        // with `refuse_ssa_request`: the peer is already inside its own commitment deadline and
5437        // learns nothing from a refusal it does not learn from that, and replying at all would hand
5438        // back some of the amplification the request was reaching for.
5439        let max_ssas_per_request = self.cfg.max_ssas_per_ssa_request;
5440        if msg.missing.len() > max_ssas_per_request {
5441            return Err(SessionManagerError::Unacceptable(format!(
5442                "Exit asked for retransmissions across {} SSA cycles in a single request, at most \
5443                 {max_ssas_per_request} allowed",
5444                msg.missing.len()
5445            ))
5446            .into());
5447        }
5448
5449        let pix_toolbox = self.pix_toolbox.get().cloned().ok_or(SessionManagerError::NotStarted)?;
5450        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
5451        let session_id = msg.session_id;
5452
5453        for (ssa_index, runs) in msg.missing {
5454            // Rebuilt from the bytes that went out the first time, so the peer's already-filled slots
5455            // see a re-delivery rather than a contradiction.
5456            let pix_toolbox_clone = pix_toolbox.clone();
5457            let commitment = hopr_utils::parallelize::cpu::spawn_blocking(
5458                move || pix_toolbox_clone.share_generator.recommit(&pseudonym, ssa_index, runs),
5459                "client_ssa_recommitment",
5460            )
5461            .await
5462            .map_err(SessionManagerError::other)?
5463            .map_err(SessionManagerError::PixError)?;
5464
5465            let commitment_msgs =
5466                SsaClientCommitmentMessage::new_multiple(session_id, commitment).map_err(SessionManagerError::other)?;
5467            let num_messages = commitment_msgs.len();
5468
5469            for commitment_msg in commitment_msgs {
5470                send_via_msg_sender(
5471                    &mut msg_sender,
5472                    session_slot.routing_opts.clone(),
5473                    HoprStartProtocol::SsaCommit(commitment_msg),
5474                    "re-sent client SSA commitment message",
5475                )
5476                .await?;
5477            }
5478
5479            info!(%session_id, %ssa_index, num_messages, "re-sent the requested SSA commitment parts");
5480        }
5481
5482        Ok(())
5483    }
5484
5485    /// Handled by the Entry, when the Exit sends PIX initiation request
5486    #[tracing::instrument(level = "debug", skip(self, msg))]
5487    async fn handle_ssa_request(
5488        &self,
5489        pseudonym: HoprPseudonym,
5490        msg: SsaServerCommitmentMessage<SessionId, HoprPixGroupElement, HoprPixDepositPayload>,
5491    ) -> errors::Result<()> {
5492        let Some(pix_toolbox) = self.pix_toolbox.get().cloned() else {
5493            return Err(SessionManagerError::UnsupportedMessage.into());
5494        };
5495
5496        if pseudonym != msg.session_id {
5497            error!(%pseudonym, %msg.session_id, "received SSA server commitment for a different session");
5498            return Err(SessionManagerError::NonExistingSession.into());
5499        }
5500
5501        // The SsaRequest can arrive before new_session() or handle_incoming_session_initiation
5502        // has finished allocating the session slot, since both SessionEstablished and SsaRequest
5503        // are sent by the Exit back-to-back and processed concurrently by the Start protocol handler.
5504        // Instead of busy-looping, await the allocation notification for this specific SessionId.
5505        let session_slot = {
5506            use std::collections::hash_map::Entry;
5507            let session_id = msg.session_id;
5508            let waiter_deadline = Instant::now() + Duration::from_secs(30);
5509            loop {
5510                if Instant::now() >= waiter_deadline {
5511                    error!(%session_id, "session slot waiter deadline exceeded");
5512                    return Err(SessionManagerError::NonExistingSession.into());
5513                }
5514                // Optimistic cache check
5515                if let Some(slot) = self.sessions.get(&session_id) {
5516                    break slot;
5517                }
5518
5519                // Register a waiter under the lock, then recheck the cache to avoid the
5520                // TOCTOU race between the initial cache check and waiter registration.
5521                let (tx, rx) = oneshot::channel::<()>();
5522                {
5523                    let mut map = self.slot_allocated.lock().unwrap_or_else(|e| e.into_inner());
5524                    // Recheck while holding the lock: the slot may have been inserted
5525                    // between the optimistic check and now. If so, don't register.
5526                    if self.sessions.get(&session_id).is_some() {
5527                        drop(map);
5528                        continue;
5529                    }
5530                    map.entry(session_id).or_default().push(tx);
5531                }
5532
5533                let timeout = futures_time::time::Duration::from(Duration::from_millis(1000));
5534                match rx.timeout(timeout).await {
5535                    Ok(Ok(())) => {
5536                        // Notified — recheck the cache
5537                        continue;
5538                    }
5539                    _ => {
5540                        // Timeout or the sender was dropped (cancelled). Clean up our
5541                        // waiter entry to avoid unbounded accumulation in the map.
5542                        let mut map = self.slot_allocated.lock().unwrap_or_else(|e| e.into_inner());
5543                        if let Entry::Occupied(mut e) = map.entry(session_id) {
5544                            e.get_mut().retain(|w| !w.is_canceled());
5545                            if e.get().is_empty() {
5546                                e.remove();
5547                            }
5548                        }
5549                        drop(map);
5550
5551                        // Final cache recheck before giving up — the slot might have
5552                        // been inserted while we were cleaning up.
5553                        if let Some(slot) = self.sessions.get(&session_id) {
5554                            break slot;
5555                        }
5556
5557                        error!(%session_id, "session slot not found after awaiting allocation");
5558                        return Err(SessionManagerError::NonExistingSession.into());
5559                    }
5560                }
5561            }
5562        };
5563
5564        debug!(
5565            num_server_commitments = msg.commitments.len(),
5566            "received Exit SSA commitments"
5567        );
5568
5569        // Cap how many SSAs a single request may ask us to commit to.
5570        //
5571        // The wire format alone permits `MAX_SSAS_PER_REQUEST` (27) commitments per message, and
5572        // every accepted entry costs a full `new_ssa_commitment` (hundreds of thousands of EC
5573        // commitments) plus thousands of outbound `SsaCommit` packets, and emits its own
5574        // `ReadyToDeposit` — i.e. its own on-chain deposit. Without a cap, a single packet from a
5575        // misbehaving Exit amplifies into minutes of Entry CPU, a large packet burst, and up to 27
5576        // simultaneous deposits bounded only by the per-deposit allocation limit.
5577        //
5578        // Rejecting the whole message rather than the surplus is deliberate: a truncated batch would
5579        // leave the Exit holding reconstructor cycles for indices it will never receive commitments
5580        // for, which its own kill switch then has to clean up.
5581        let max_ssas_per_request = self.cfg.max_ssas_per_ssa_request;
5582        if msg.commitments.len() > max_ssas_per_request {
5583            let error = SessionManagerError::Unacceptable(format!(
5584                "Exit requested {} SSA commitments in a single request, at most {max_ssas_per_request} allowed",
5585                msg.commitments.len()
5586            ));
5587            self.refuse_ssa_request(msg.session_id, session_slot.routing_opts.clone())
5588                .await;
5589            return Err(error.into());
5590        }
5591
5592        let Some(our_params) = session_slot.current_ssa_state.get().map(|s| s.params) else {
5593            return Err(
5594                SessionManagerError::Other(anyhow::anyhow!("no SSA state for session {}", msg.session_id)).into(),
5595            );
5596        };
5597
5598        // The Entry enforces that the Exit's SSA parameters match exactly the ones we offered in the
5599        // Session Initiation message.  Negotiation (accepting an Exit-chosen quota within our
5600        // bounds) is not implemented, so any mismatch is rejected.
5601        //
5602        // The whole quadruple is compared rather than the scalar quota it implies.  Quota equality
5603        // was once argued to be sufficient — the Exit does not pick the dimensions independently, so
5604        // a matching product implied matching `(polys, shares)` from any Exit running unmodified
5605        // code.  The quota now prices three of the four, so it is no longer blind to the surplus,
5606        // but it is still a product: it cannot tell `polys x threshold` from a transposition of the
5607        // two, and those are not interchangeable to the protocol.  Comparing the params is both
5608        // stricter and simpler, and costs nothing now that all four travel together.
5609        //
5610        // The fourth, the curve suite, is why the Entry needs no separate curve check of its own:
5611        // the Exit refused a foreign suite before it sent this message, and an Exit that echoed a
5612        // different one back than the Entry offered fails right here.
5613        //
5614        // Malformed params never reach this comparison as a mismatch: `dimensions()` fails first,
5615        // and that failure takes the same refusal path, so the Exit is told either way rather than
5616        // being left waiting on a dropped packet.
5617        let server_params = match msg.dimensions() {
5618            Ok(params) => params,
5619            Err(error) => {
5620                self.refuse_ssa_request(msg.session_id, session_slot.routing_opts.clone())
5621                    .await;
5622                return Err(
5623                    SessionManagerError::Unacceptable(format!("Exit sent malformed PIX parameters: {error}")).into(),
5624                );
5625            }
5626        };
5627        if our_params != server_params {
5628            let error = SessionManagerError::Unacceptable(format!(
5629                "Exit sent unacceptable PIX parameters {server_params} (ours are {our_params})"
5630            ));
5631            self.refuse_ssa_request(msg.session_id, session_slot.routing_opts.clone())
5632                .await;
5633            return Err(error.into());
5634        }
5635        // A request naming missing commitment parts asks for nothing new — see
5636        // `SsaServerCommitmentMessage` — and none of what follows may run for it.
5637        //
5638        // That placement is the whole safety argument for reusing this message. The successor gate
5639        // below would refuse an index that is *already* being served, which is every index a
5640        // retransmission can name; the served-packet gate prices service against a batch nobody is
5641        // asking for; and `new_ssa_commitment` rejects an index at or below the watermark, which
5642        // again is every index this can name. Above this line is what a retransmission does need:
5643        // the pseudonym check, the session slot, and the parameter comparison.
5644        if !msg.missing.is_empty() {
5645            return self.handle_ssa_recommit(pseudonym, &session_slot, msg).await;
5646        }
5647
5648        let quota_per_ssa = pix_params_to_quota(&our_params);
5649
5650        // Everything from here to the end of the batch is serialised per pseudonym. Start messages run
5651        // under `for_each_concurrent`, so the successor gate below would otherwise be read by several
5652        // requests before any of them advanced it, and every one would pass. See `ssa_request_locks`.
5653        let request_lock = self
5654            .ssa_request_locks
5655            .get_with(pseudonym, async { Arc::new(futures::lock::Mutex::new(())) })
5656            .await;
5657        let _request_guard = request_lock.lock().await;
5658
5659        // Successor gate, the Entry's half of the one in `SessionPixSupervisor`. A correct Exit asks
5660        // for the next batch when the *last* cycle of the current one is nearly recovered, by which
5661        // point we have long been emitting that cycle's shares. An Exit that asks earlier is asking
5662        // for deposits it cannot have earned, so nothing here commits and nothing is deposited.
5663        //
5664        // Two conditions, and the second is what the first alone cannot give. Emission must have
5665        // reached the last committed cycle — which, with the window clamped to one cycle, means every
5666        // earlier one is exhausted — *and* it must be far enough into that cycle. Checking only the
5667        // index admitted a successor batch on the last cycle's very first share, i.e. ~0 % of the way
5668        // through the batch rather than the ~83 % at which a conforming Exit asks. That is nearly a
5669        // whole cycle of unearned deposits, on a gate whose entire purpose is to prevent them.
5670        //
5671        // The boundary is derived rather than guessed — see `min_emission_for_early_recovery`, which
5672        // accounts for the Exit counting *polynomials* and for emission running in lockstep windows
5673        // that spend their whole surplus before the next window starts. A flat fraction of the cycle
5674        // gets this badly wrong in the unsafe direction: 0.85 over the 3.47× surplus factor is 24.5 %,
5675        // against a true boundary of 83.0 % at the deployed dimensions.
5676        //
5677        // Being early is refused rather than fatal, and deliberately not via `refuse_ssa_request`,
5678        // which closes the Session: that message is dropped and the Session left running. A correct
5679        // Exit never lands there; one that does has its own deadline for the cycles it already
5680        // allocated, and it can reach them without us having paid for anything. Lost generator state
5681        // is the one arm that *is* fatal, for the reason given at it.
5682        //
5683        // First index rather than each: `commitments` is a `BTreeMap`, so the lowest index leads, and
5684        // checking it before the loop is also what stops a batch that fails partway from having
5685        // already emitted `ReadyToDeposit` for its earlier members.
5686        //
5687        // Computed at the protocol floor rather than at our own reconstructor's threshold. The value
5688        // that decides when a correct Exit asks is *its* setting, which does not travel on the wire;
5689        // gating on ours refused a peer configured lower, silently, with no retry path — two valid
5690        // configurations that could not talk. See `MIN_EARLY_RECOVERY_THRESHOLD`, which every Exit is
5691        // held to by `validate_pix_supervision` and which is therefore the earliest any conforming
5692        // peer can ask.
5693        let min_emitted = hopr_protocol_pix::min_emission_for_early_recovery(
5694            &our_params,
5695            hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD,
5696        );
5697        match pix_toolbox.share_generator.emission_progress(&pseudonym) {
5698            Some(progress) if !progress.is_serving_last_committed() || progress.front_emitted < min_emitted => {
5699                let asked_first = msg.commitments.keys().next().copied();
5700                let error = SessionManagerError::Unacceptable(format!(
5701                    "Exit asked for SSAs from {asked_first:?} while emission has reached {:?} ({} of {min_emitted} \
5702                     shares needed for an early-recovery signal) of the batch committed up to {}",
5703                    progress.highest_emitted, progress.front_emitted, progress.last_committed
5704                ));
5705                warn!(session_id = %msg.session_id, %error, "refused an early SSA request");
5706                return Err(error.into());
5707            }
5708            // No generator state, but this Session has committed before: the entry was discarded
5709            // under us and the gate above has nothing left to measure.
5710            //
5711            // Absent state is otherwise the ordinary opening batch, which is why the gate treats it as
5712            // admissible — and that is the whole of the hole this closes. The generator keeps its
5713            // per-pseudonym entry in a cache with an idle retention, refreshed by share *emission*; a
5714            // Session kept alive on KeepAlives alone while the Entry sends nothing outlives it. An Exit
5715            // that arranges exactly that gets the successor gate deleted rather than merely relaxed —
5716            // no emission boundary, and no monotonic index either, since the discarded entry took the
5717            // high-watermark with it — and can then farm a fresh deposit per retention period.
5718            //
5719            // Terminal rather than a dropped message. The polynomials for the committed cycles went
5720            // with the entry, so `next_share` yields nothing and no share of them will ever be emitted:
5721            // whatever the Exit deposited against them is already unrecoverable and this half of the
5722            // Session can make no further PIX progress. Refusing quietly would leave the Exit to
5723            // discover that by timing out on commitments we have decided never to send.
5724            None if session_slot
5725                .current_ssa_state
5726                .get()
5727                .and_then(SessionSsaState::committed_watermark)
5728                .is_some() =>
5729            {
5730                let error = SessionManagerError::Unacceptable(format!(
5731                    "generator state for {pseudonym} was discarded while the Session was live; the cycles committed \
5732                     up to {:?} can no longer be served",
5733                    session_slot
5734                        .current_ssa_state
5735                        .get()
5736                        .and_then(SessionSsaState::committed_watermark)
5737                ));
5738                warn!(session_id = %msg.session_id, %error, "refused an SSA request against lost generator state");
5739                self.refuse_ssa_request(msg.session_id, session_slot.routing_opts.clone())
5740                    .await;
5741                return Err(error.into());
5742            }
5743            _ => {}
5744        }
5745
5746        // Second half of the successor gate, and the one that measures the Exit rather than us.
5747        //
5748        // Emission above is this node's own work: it counts shares handed to the local packet
5749        // pipeline by `create_surb_for_path`, a consumption that is not even rolled back when the
5750        // rest of the packet build fails. An Exit that requests, is funded, and then returns nothing
5751        // still walks that counter forward for as long as we keep sending, so on its own it prices
5752        // deposits against work we did to ourselves.
5753        //
5754        // What is checked here is service that actually arrived. A share is encrypted with the first
5755        // relayer's challenge solution and rides a return SURB, so the Exit can only decrypt it by
5756        // *using* that SURB — one returned packet is one SURB consumed is one share unlocked. The
5757        // Exit cannot inflate this without unlocking exactly as many shares, which advances the
5758        // recovery it is claiming to have made. Nothing it reports is trusted.
5759        //
5760        // Skipped before the first commitment: the opening batch is the one nothing has been paid
5761        // for yet, and there is no service to have been rendered against it.
5762        if let Some(state) = session_slot.current_ssa_state.get()
5763            && let Some(watermark) = state.committed_watermark()
5764        {
5765            let shares_per_cycle = our_params.polys_per_ssa() as u64 * our_params.emitted_shares_per_poly() as u64;
5766            let target = (watermark.get() as u64 - 1) * shares_per_cycle + min_emitted;
5767
5768            // Discounted, because the Exit unlocks a share when the *first relayer* acknowledges,
5769            // which is upstream of us: every packet lost after that point is progress it legitimately
5770            // has and we cannot see. Demanding the undiscounted figure would refuse conforming Exits
5771            // on any lossy path.
5772            //
5773            // By the loss a cycle actually survives, not by the surplus ratio `s/(t+s)` this used to
5774            // take. The ratio is break-even — the loss at which a polynomial receives `t` shares on
5775            // average — and a cycle needs every polynomial to clear it, so at break-even it fails. At
5776            // the deployed `4096 × (32 + 79)` the ratio is 71.2 % against a tolerance of 50.3 % — a
5777            // cycle losing 71.2 % of its shares does not recover — and discounting by it put the bar
5778            // at 23.9 % of a cycle's packets, below even the 111 424 shares a lossless Exit must have
5779            // unlocked to reach its early-recovery point. The tolerance puts the mean at 41.3 %; the bound
5780            // below sets the bar at 41.0 %.
5781            //
5782            // The discount is also what an Exit can withhold for as long as it likes and still have
5783            // every next deposit funded — one whose first return relayer is its own acknowledges each
5784            // packet, unlocking the share, and drops it. So it is the design tolerance and no more:
5785            // the loss the negotiated surplus carries at the 99 % target `default_surplus_for` sizes
5786            // it to. That is the tolerance the *Entry* bought, as insurance against the return
5787            // relayers it picked — one expecting unreliable relayers buys more surplus and thereby
5788            // loosens its own gate. How strict the gate is follows from that choice; it is not a knob
5789            // of its own, and the Exit, which judges an offer by its quota alone, does not set it.
5790            //
5791            // Not at the mean of what an Exit losing that much returns, though, but well below it. One
5792            // losing exactly the tolerance downstream of its first return relayer still unlocks every
5793            // share and asks at the lossless boundary, and the packets of it that reach us are
5794            // `Binomial(target, 1 − L)`: short of their mean half the time, and at the deployed split
5795            // one time in five by more than the near-miss window below — refused at once and, with
5796            // `RequestSsa` never retried, the Session with it. `SUCCESSOR_GATE_ARRIVAL_SIGMAS`
5797            // standard deviations under the mean make that negligible for a fraction of a percent
5798            // more that an Exit can withhold. The deviation is the same binomial's, so the bound still
5799            // follows the split the Entry chose; a split without surplus is demanded its whole target.
5800            let required = successor_required_packets(target, our_params.tolerated_share_loss());
5801
5802            let served = |slot: &SessionSlot| {
5803                state.served_since_first_commit(slot.returned_packets.load(std::sync::atomic::Ordering::Relaxed))
5804            };
5805            let mut observed = served(&session_slot);
5806
5807            if observed < required {
5808                // A conforming Exit asks the instant its reconstructor crosses the threshold, and the
5809                // request travels the same mixed path as the packets that earned it — so it can
5810                // overtake the last few of them. Refusing that is not a refusal at all: `RequestSsa`
5811                // is emitted once per index and never retried, so the Exit sits in
5812                // `AwaitingCommitment` until `max_ssa_delivery_time` and then closes the Session.
5813                // A short wait costs nothing and saves the Session.
5814                //
5815                // Entered only for a near miss, and that is what keeps the wait from being a lever:
5816                // this handler runs under a bounded `for_each_concurrent` and holds the
5817                // per-pseudonym request lock, so an Exit must already have returned all but one
5818                // emission window of what it owes to occupy either. Anything further out is refused
5819                // on the spot, at no cost to us.
5820                //
5821                // One window rather than a fraction of `required`: it is the unit emission advances
5822                // in, so a shortfall below it is genuinely in-flight, and a percentage would round to
5823                // zero at exactly the small dimensions where this matters most.
5824                let near_miss = observed > 0 && required - observed <= hopr_protocol_pix::SHARE_EMISSION_WINDOW as u64;
5825                if near_miss {
5826                    let deadline = Instant::now() + SSA_SUCCESSOR_SERVICE_WAIT;
5827                    while observed < required && Instant::now() < deadline {
5828                        hopr_utils::runtime::prelude::sleep(SSA_SUCCESSOR_SERVICE_POLL).await;
5829                        observed = served(&session_slot);
5830                    }
5831                }
5832            }
5833
5834            if observed < required {
5835                let error = SessionManagerError::Unacceptable(format!(
5836                    "Exit asked for SSAs having returned {observed} of the {required} packets its batch committed up \
5837                     to {watermark} has been paid for"
5838                ));
5839                warn!(session_id = %msg.session_id, %error, "refused an under-served SSA request");
5840                return Err(error.into());
5841            }
5842        }
5843
5844        let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
5845        let session_id = msg.session_id;
5846
5847        // The server can theoretically send multiple SSA commitments asking us to make the equal
5848        // number of client commitments and deposits, and the batch is all-or-nothing: either every
5849        // member gets a commitment and a `ReadyToDeposit`, or none does.
5850        //
5851        // Hence three phases rather than one loop. Interleaving them meant a batch whose *second*
5852        // exit commitment failed to decode had already sent the first member's `SsaCommit` burst and
5853        // emitted its `ReadyToDeposit` — an on-chain deposit instruction — before the failure was
5854        // reached. The Exit, whose own request was rejected as a whole, has no cycle to spend it on.
5855        //
5856        // The server is authoritative in giving the ssa_index; the client only verifies that it is
5857        // strictly monotonic. That monotonicity is enforced inside `new_ssa_commitment` in phase two,
5858        // which rejects any `ssa_index` that is `<=` the last one generated for this pseudonym with
5859        // `PixError::InvalidInput` (see `SsaShareGenerator::new_ssa_commitment`). Because that call
5860        // happens in a phase before anything is published, a stale, duplicate, or out-of-order
5861        // `SsaRequest` cannot cause a deposit — the whole message is rejected first. The
5862        // per-pseudonym baseline lives in the generator's `polynomials` cache (30-min idle TTL,
5863        // refreshed on every use), so it persists for the life of an active session. Gaps (an index
5864        // strictly greater than the last, but not the immediate successor) are allowed by design,
5865        // since the Exit may advance by more than one SSA at a time.
5866
5867        // Phase 1 — validate. Decoding is the only step that consumes attacker-supplied bytes:
5868        // `try_into_pix_group` decompresses the point and rejects anything outside the prime-order
5869        // subgroup. Doing every one of them up front is what makes a malformed later member cost
5870        // nothing, and it is cheap relative to phase two.
5871        // Taken out before the loop consumes `msg.commitments`, and drained as the loop goes: an SSA
5872        // is committed to once, so its deposit data is needed once.
5873        let mut deposit_payloads = msg.deposit_data;
5874
5875        let mut validated = Vec::with_capacity(msg.commitments.len());
5876        for (ssa_index, exit_commitment) in msg.commitments {
5877            trace!(ssa_index, "received Exit SSA commitment");
5878            match exit_commitment.try_into_pix_group() {
5879                Ok(point) => validated.push((ssa_index, point)),
5880                Err(error) => {
5881                    // Terminal, like every other unacceptable-parameter case here. A peer that cannot
5882                    // produce a valid group element is not going to produce one on a retry, and the
5883                    // Exit would otherwise wait out `max_ssa_delivery_time` on commitments that are
5884                    // never coming.
5885                    let error = SessionManagerError::Unacceptable(format!(
5886                        "Exit sent an undecodable SSA commitment for index {ssa_index}: {error}"
5887                    ));
5888                    self.refuse_ssa_request(session_id, session_slot.routing_opts.clone())
5889                        .await;
5890                    return Err(error.into());
5891                }
5892            }
5893        }
5894
5895        // Phase 2 — stage. Generates every client commitment and derives every deposit address,
5896        // publishing none of them. This is the expensive phase, and it still mutates the generator:
5897        // `new_ssa_commitment` appends to the per-pseudonym polynomial queue. Failing here therefore
5898        // leaves queued polynomials the Exit never learns of — wasted work rather than a leaked
5899        // deposit instruction, and the generator's own monotonic index keeps them from being mistaken
5900        // for a later cycle's.
5901        let mut staged = Vec::with_capacity(validated.len());
5902        for (ssa_index, exit_point) in validated {
5903            // Use the global `pix_toolbox.share_generator` to generate the client
5904            // commitment. The generator is shared with the packet pipeline's
5905            // `next_share`, so polynomials created here will be used when the
5906            // pipeline embeds PIX shares into return-path SURBs.
5907            //
5908            // The generator dimension (polys × threshold) must match what the
5909            // Exit's reconstructor expects — both are set from the session's
5910            // negotiated PIX params (pix_global_config on Entry → SsaRequest
5911            // params on Exit).  If the client sends commitments that exceed the
5912            // Exit's expected dimensions, the Exit rejects them as InvalidInput.
5913            let pix_toolbox_clone = pix_toolbox.clone();
5914            let client_commitment = hopr_utils::parallelize::cpu::spawn_blocking(
5915                move || {
5916                    pix_toolbox_clone
5917                        .share_generator
5918                        .new_ssa_commitment(&pseudonym, ssa_index)
5919                },
5920                "client_ssa_commitment",
5921            )
5922            .await
5923            .map_err(SessionManagerError::other)?
5924            .map_err(SessionManagerError::PixError)?;
5925
5926            // The generator has been advanced for this index, so the Session records it now rather
5927            // than after the publish phase. Recording it early is the safe direction: the watermark
5928            // only ever makes the successor gate stricter, and a batch abandoned between here and
5929            // publication has still moved the generator.
5930            if let Some(state) = session_slot.current_ssa_state.get() {
5931                state.note_committed(
5932                    ssa_index,
5933                    session_slot.returned_packets.load(std::sync::atomic::Ordering::Relaxed),
5934                );
5935            }
5936
5937            // Construct the full SSA by adding the client and exit commitments, getting the deposit address
5938            let full_ssa = client_commitment.ssa_commitment + exit_point;
5939            let deposit_address = HoprPixSpec::group_to_deposit_address(full_ssa).ok_or(SessionManagerError::other(
5940                anyhow::anyhow!("failed to convert SSA to deposit address"),
5941            ))?;
5942
5943            // Split the SSA client commitment into Start protocol commitment messages
5944            let commitment_msgs = SsaClientCommitmentMessage::new_multiple(session_id, client_commitment)
5945                .map_err(SessionManagerError::other)?;
5946            debug!(%ssa_index, count = commitment_msgs.len(), "generated client SSA commitment messages");
5947
5948            staged.push((ssa_index, deposit_address, commitment_msgs));
5949        }
5950
5951        // Phase 3 — publish. Nothing below can fail on the *content* of the request; only the
5952        // transport can, and a transport that has stopped accepting messages fails the Session
5953        // regardless of where in the batch it happens.
5954        for (ssa_index, deposit_address, commitment_msgs) in staged {
5955            // Send each commitment message into the message sender
5956            for commitment_msg in commitment_msgs {
5957                send_via_msg_sender(
5958                    &mut msg_sender,
5959                    session_slot.routing_opts.clone(),
5960                    HoprStartProtocol::SsaCommit(commitment_msg),
5961                    "client SSA commitment message",
5962                )
5963                .await?;
5964            }
5965
5966            debug!(%ssa_index, "all Entry SSA commitment messages were sent out");
5967
5968            // Notify the new SSA deposit address *after* all commitment messages have been
5969            // sent out successfully, so the deposit cannot begin before the Exit has the
5970            // complete commitment to reconstruct the deposit key.
5971            pix_toolbox
5972                .pix_events
5973                .try_send(HoprSessionOutPixEvent::ReadyToDeposit(AgreedSsaQuota {
5974                    ssa_id: SsaId::new(pseudonym, ssa_index),
5975                    deposit_address,
5976                    quota_per_ssa,
5977                    // Rebuilt rather than carried: the `id` is not on the wire, because this message
5978                    // already says which Session and which SSA these bytes belong to.
5979                    deposit_data: PixDepositData {
5980                        id: PixAddressId::new(&pseudonym, ssa_index),
5981                        data: deposit_payloads
5982                            .remove(&ssa_index)
5983                            .map(|payload| payload.0)
5984                            .unwrap_or_default(),
5985                    },
5986                }))
5987                .map_err(|_| SessionManagerError::other(anyhow::anyhow!("failed to notify new deposit ssa")))?;
5988            info!(%ssa_index, %deposit_address, quota_per_ssa, "generated client SSA commitment and deposit address");
5989        }
5990
5991        trace!(quota_per_ssa, "Exit commitment message has been fully processed");
5992        Ok(())
5993    }
5994}
5995
5996#[cfg(test)]
5997mod tests {
5998    use anyhow::{Context, anyhow};
5999    use futures::{AsyncWriteExt, channel::mpsc::UnboundedSender, future::BoxFuture, pin_mut};
6000    use hopr_api::types::{
6001        crypto::{keypairs::ChainKeypair, prelude::Keypair},
6002        crypto_random::Randomizable,
6003        internal::routing::SurbMatcher,
6004        primitive::prelude::Address,
6005    };
6006    use hopr_crypto_packet::prelude::{PacketSignal, PacketSignals};
6007    use hopr_protocol_pix::{SsaGeneratorConfig, SsaIndex, SsaReconstructorConfig};
6008    use hopr_protocol_start::{StartProtocol, StartProtocolDiscriminants};
6009    use hopr_utils::network_types::prelude::SealedHost;
6010    use moka::future::FutureExt;
6011    use tokio::time::timeout;
6012
6013    use super::*;
6014    use crate::{Capabilities, balancer::SurbBalancerConfig, types::SessionTarget};
6015
6016    /// A [`PixToolbox`] whose deposit-data requests are answered, for tests that do not read the
6017    /// PIX event stream themselves.
6018    ///
6019    /// An Exit cannot send an `SsaRequest` without the pool answering, so a test that only needs the
6020    /// request to *happen* would otherwise be asserting against `MissingDepositData`.
6021    fn pix_toolbox_with_pool(
6022        generator: Arc<SsaShareGenerator<HoprPixSpec>>,
6023        reconstructor: Arc<SsaReconstructor<HoprPixSpec>>,
6024    ) -> PixToolbox {
6025        let (toolbox, events) = PixToolbox::new(generator, reconstructor);
6026        // The answering task owns `events`, and lives until the toolbox is dropped. The forwarded
6027        // stream is discarded: these tests do not read PIX events.
6028        drop(crate::testing::answering_deposit_pool(events, |_| Vec::new()));
6029        toolbox
6030    }
6031
6032    /// `StartInitiation::additional_data` as an Entry offering these dimensions would send it.
6033    ///
6034    /// Tests go through the same packing production does. They used to write the shifts out by
6035    /// hand, which meant a change to the layout altered what every one of them was asserting
6036    /// without altering a single line of them — plain `u64` literals type-check against anything.
6037    fn pix_additional_data(polys_per_ssa: u16, shares_per_poly: u8, surplus_shares: u8) -> u128 {
6038        into_initiation_additional_data(
6039            PixParams::try_new(polys_per_ssa, shares_per_poly, surplus_shares, LOCAL_PIX_SUITE)
6040                .expect("test dimensions must be valid")
6041                .into_additional_data(0),
6042        )
6043    }
6044
6045    /// The default test dimensions: the smallest legal split, with the surplus the test generators
6046    /// below are configured with.
6047    fn small_pix_params() -> PixParams {
6048        PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE).expect("test dimensions must be valid")
6049    }
6050
6051    /// [`small_pix_params`] as an Entry offering them would pack them into `additional_data`.
6052    fn small_pix_additional_data() -> u128 {
6053        into_initiation_additional_data(small_pix_params().into_additional_data(0))
6054    }
6055
6056    /// Surplus used by the small test `SsaGeneratorConfig`s below. Non-zero and different from
6057    /// [`SsaGeneratorConfig::default`], so a value that failed to cross the wire is visible.
6058    const TEST_SURPLUS_SHARES: u8 = 1;
6059
6060    #[test]
6061    fn session_config_forwards_max_buffered_segments() {
6062        assert_eq!(
6063            SessionManagerConfig::default().max_buffered_segments,
6064            0,
6065            "default must leave the transport unbuffered"
6066        );
6067
6068        for segments in [0, 64] {
6069            let cfg = SessionManagerConfig {
6070                max_buffered_segments: segments,
6071                ..Default::default()
6072            };
6073            assert_eq!(
6074                session_config(&cfg, Capabilities::empty()).max_buffered_segments,
6075                segments
6076            );
6077        }
6078    }
6079
6080    /// The head-of-line bound must reach a session that cannot recover a missing frame, and must
6081    /// not reach one that can.
6082    ///
6083    /// Both halves matter. Without the first, a session with no retransmission holds every frame
6084    /// behind a gap for the full frame timeout, waiting for something that is never coming —
6085    /// measured on a cluster as 98.5 % of bytes arriving over the wire and 0.60 % reaching the
6086    /// application. Without the second, a session that *would* have retransmitted the gap instead
6087    /// abandons it, turning recoverable frames into loss.
6088    #[test]
6089    fn session_config_should_bound_the_gap_only_without_retransmission() {
6090        assert_eq!(
6091            SessionManagerConfig::default().max_frames_behind_gap,
6092            Some(256),
6093            "the default must bound the gap, or the stall stays in place unless opted out of"
6094        );
6095
6096        let cfg = SessionManagerConfig {
6097            max_frames_behind_gap: Some(8),
6098            ..Default::default()
6099        };
6100
6101        for reliable in [Capability::RetransmissionAck, Capability::RetransmissionNack] {
6102            assert_eq!(
6103                session_config(&cfg, reliable.into()).max_frames_behind_gap,
6104                None,
6105                "{reliable:?} can recover the gap, so the wait is productive and must be left alone"
6106            );
6107        }
6108
6109        for unreliable in [Capabilities::empty(), Capability::Segmentation.into()] {
6110            assert_eq!(
6111                session_config(&cfg, unreliable).max_frames_behind_gap,
6112                Some(8),
6113                "without retransmission the gap must be bounded"
6114            );
6115        }
6116    }
6117
6118    /// The right value tracks throughput x latency spread, which is a property of the *session*,
6119    /// not of the node: a bulk data session and a control session on the same node have entirely
6120    /// different reordering depths. A caller that knows its own traffic must be able to say so.
6121    #[test]
6122    fn a_session_should_be_able_to_override_the_nodes_gap_bound() {
6123        let node = SessionManagerConfig {
6124            max_frames_behind_gap: Some(256),
6125            ..Default::default()
6126        };
6127
6128        assert_eq!(
6129            session_config_with(&node, Capabilities::empty(), Some(16)).max_frames_behind_gap,
6130            Some(16),
6131            "the session's own value must win over the node default"
6132        );
6133        assert_eq!(
6134            session_config_with(&node, Capabilities::empty(), None).max_frames_behind_gap,
6135            Some(256),
6136            "saying nothing must inherit the node default"
6137        );
6138        assert_eq!(
6139            session_config_with(&node, Capabilities::empty(), Some(0)).max_frames_behind_gap,
6140            None,
6141            "zero disables the bound for this session, matching the env knob's semantics"
6142        );
6143    }
6144
6145    /// A per-session override must not be able to re-enable the bound where waiting is productive.
6146    #[test]
6147    fn a_session_override_should_not_reach_a_session_that_can_retransmit() {
6148        let node = SessionManagerConfig::default();
6149        assert_eq!(
6150            session_config_with(&node, Capability::RetransmissionAck.into(), Some(4)).max_frames_behind_gap,
6151            None,
6152            "retransmission can recover the gap, so no caller should be able to cut the wait short"
6153        );
6154    }
6155
6156    /// Disabling has to be reachable, since the bound trades reordering tolerance for latency and
6157    /// the right value is deployment-specific. `None` restores the previous behaviour exactly.
6158    #[test]
6159    fn session_config_should_allow_the_gap_bound_to_be_disabled() {
6160        let cfg = SessionManagerConfig {
6161            max_frames_behind_gap: None,
6162            ..Default::default()
6163        };
6164        assert_eq!(session_config(&cfg, Capabilities::empty()).max_frames_behind_gap, None);
6165    }
6166
6167    #[test]
6168    fn a_zero_gap_bound_should_disable_it_at_the_node_level_too() {
6169        // `0` means "not for me" wherever it is written. Read literally it would be the strictest
6170        // possible bound -- abandon the gap before a single frame arrives behind it -- so the two
6171        // levels would mean opposite things by the same value.
6172        let cfg = SessionManagerConfig {
6173            max_frames_behind_gap: Some(0),
6174            ..Default::default()
6175        };
6176        assert_eq!(session_config(&cfg, Capabilities::empty()).max_frames_behind_gap, None);
6177    }
6178
6179    /// Balancer state with the given target sitting at the given level.
6180    fn gate_state(target: u64, buffer_level: u64) -> BalancerStateValues {
6181        let state = BalancerStateValues::new(SurbBalancerConfig {
6182            target_surb_buffer_size: target,
6183            ..Default::default()
6184        });
6185        state
6186            .buffer_level
6187            .store(buffer_level, std::sync::atomic::Ordering::Relaxed);
6188        state
6189    }
6190
6191    /// A packet short enough to have room for a SURB. `max_surbs_with_message` is
6192    /// `(PAYLOAD_SIZE - len) / SURB_SIZE`, so anything past roughly half the payload already caps
6193    /// itself at zero and would make the assertions below pass for the wrong reason.
6194    fn small_outgoing_packet() -> anyhow::Result<ApplicationDataOut> {
6195        let data = ApplicationDataOut::with_no_packet_info(ApplicationData::new(SESSION_APPLICATION_TAG, b"short")?);
6196        anyhow::ensure!(
6197            data.estimate_surbs_with_msg() >= 1,
6198            "fixture must have room for a SURB, otherwise the cap is not what is being measured"
6199        );
6200        Ok(data)
6201    }
6202
6203    /// `account_incoming_surbs` must keep the estimator's held count (`saturating_diff`) equal to
6204    /// what a bounded store of capacity `CAP` would actually hold across a fill-past-capacity-then-drain
6205    /// sequence. The eviction term is what makes the held count fall back to zero once the overflow is
6206    /// drained -- and so what stops the keep-alive report and the PIX fill reserve from reading a level
6207    /// that never falls.
6208    #[test]
6209    fn account_incoming_surbs_tracks_a_bounded_store_through_overflow_and_drain() {
6210        const CAP: u64 = 100;
6211        let estimator = AtomicSurbFlowEstimator::default();
6212        let mut occupancy: u64 = 0;
6213
6214        let mut receive = |batch: u64| {
6215            let evicted = (occupancy + batch).saturating_sub(CAP);
6216            occupancy = (occupancy + batch).min(CAP);
6217            account_incoming_surbs(&estimator, batch, evicted);
6218        };
6219
6220        for _ in 0..5 {
6221            receive(40);
6222        }
6223        assert_eq!(occupancy, CAP, "the model store is full");
6224        assert_eq!(
6225            estimator.saturating_diff(),
6226            CAP,
6227            "held count tracks a full store, not the {}-SURB total received",
6228            5 * 40
6229        );
6230
6231        for _ in 0..CAP {
6232            estimator.consumed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6233            occupancy -= 1;
6234        }
6235        assert_eq!(occupancy, 0, "the model store is empty");
6236        assert_eq!(
6237            estimator.saturating_diff(),
6238            0,
6239            "held count returns to zero after the drain; the evicted surplus is not counted as held"
6240        );
6241    }
6242
6243    /// The gate primitive itself mints whenever the level is below the effective target. A level
6244    /// short of target with no capacity inferred yet still mints -- the stop for a smaller-than-target
6245    /// store comes from inferring its capacity over successive reports (see
6246    /// `a_plateau_below_target_infers_the_counterparty_capacity` in `balancer/controller.rs`), which
6247    /// lowers the effective target rather than changing this primitive.
6248    #[test]
6249    fn organic_minting_continues_below_target_without_an_inferred_capacity() -> anyhow::Result<()> {
6250        const EXIT_CAPACITY: u64 = 500;
6251        const ENTRY_TARGET: u64 = 2_000;
6252
6253        let mut data = small_outgoing_packet()?;
6254        cap_organic_surbs(&mut data, 1, &gate_state(ENTRY_TARGET, EXIT_CAPACITY));
6255
6256        assert_eq!(Some(1), data.packet_info.map(|i| i.max_surbs_in_packet));
6257        assert_eq!(
6258            1,
6259            data.estimate_surbs_with_msg(),
6260            "below the effective target with no inferred capacity, the Entry mints"
6261        );
6262        Ok(())
6263    }
6264
6265    #[test]
6266    fn cap_organic_surbs_should_zero_the_packet_once_the_counterparty_is_at_target() -> anyhow::Result<()> {
6267        let mut data = small_outgoing_packet()?;
6268        cap_organic_surbs(&mut data, 1, &gate_state(100, 200));
6269
6270        assert_eq!(Some(0), data.packet_info.map(|i| i.max_surbs_in_packet));
6271        assert_eq!(
6272            0,
6273            data.estimate_surbs_with_msg(),
6274            "the cap must reach the SURB accounting, not just the packet info"
6275        );
6276        Ok(())
6277    }
6278
6279    #[test]
6280    fn cap_organic_surbs_should_allow_one_while_the_counterparty_is_below_target() -> anyhow::Result<()> {
6281        let mut data = small_outgoing_packet()?;
6282        cap_organic_surbs(&mut data, 1, &gate_state(100, 10));
6283
6284        assert_eq!(Some(1), data.packet_info.map(|i| i.max_surbs_in_packet));
6285        assert_eq!(1, data.estimate_surbs_with_msg());
6286        Ok(())
6287    }
6288
6289    /// The cap writes into a field it may have to create, so it must not take the rest of the packet
6290    /// info with it — the routing stage sets the outgoing signals on this same struct.
6291    #[test]
6292    fn cap_organic_surbs_should_not_clobber_existing_packet_info() -> anyhow::Result<()> {
6293        let mut data = small_outgoing_packet()?;
6294        data.packet_info = Some(OutgoingPacketInfo {
6295            signals_to_destination: PacketSignal::SurbDistress.into(),
6296            max_surbs_in_packet: usize::MAX,
6297            surb_generation: Some(7),
6298        });
6299
6300        cap_organic_surbs(&mut data, 1, &gate_state(100, 200));
6301
6302        let info = data.packet_info.expect("packet info must survive");
6303        assert_eq!(0, info.max_surbs_in_packet);
6304        assert_eq!(
6305            PacketSignals::from(PacketSignal::SurbDistress),
6306            info.signals_to_destination
6307        );
6308        assert_eq!(
6309            Some(7),
6310            info.surb_generation,
6311            "the generation captured at path resolution must survive the cap"
6312        );
6313        Ok(())
6314    }
6315
6316    /// `usize::MAX` is an explicit statement by the client about its own traffic, so it
6317    /// wins over the balancer's estimate — and must leave the packet entirely untouched rather than
6318    /// writing some larger cap.
6319    #[test]
6320    fn maxing_out_surbs_should_bypass_the_balancer_gate() -> anyhow::Result<()> {
6321        let mut data = small_outgoing_packet()?;
6322        cap_organic_surbs(&mut data, usize::MAX, &gate_state(100, 200));
6323
6324        assert_eq!(None, data.packet_info, "the opt-in must not touch the packet at all");
6325        assert!(data.estimate_surbs_with_msg() >= 1);
6326        Ok(())
6327    }
6328
6329    #[test]
6330    fn cap_organic_surbs_should_honor_explicit_packet_limits() -> anyhow::Result<()> {
6331        for limit in [0, 2] {
6332            for buffer_level in [10, 200] {
6333                let mut data = small_outgoing_packet()?;
6334                cap_organic_surbs(&mut data, limit, &gate_state(100, buffer_level));
6335
6336                assert_eq!(Some(limit), data.packet_info.map(|info| info.max_surbs_in_packet));
6337                assert_eq!(limit, data.estimate_surbs_with_msg());
6338            }
6339        }
6340        Ok(())
6341    }
6342
6343    #[async_trait::async_trait]
6344    trait SendMsg {
6345        async fn send_message(
6346            &self,
6347            routing: DestinationRouting,
6348            data: ApplicationDataOut,
6349        ) -> crate::errors::Result<()>;
6350    }
6351
6352    mockall::mock! {
6353        MsgSender {}
6354        impl SendMsg for MsgSender {
6355            fn send_message<'a, 'b>(&'a self, routing: DestinationRouting, data: ApplicationDataOut)
6356            -> BoxFuture<'b, crate::errors::Result<()>> where 'a: 'b, Self: Sync + 'b;
6357        }
6358    }
6359
6360    fn mock_packet_planning(
6361        sender: MockMsgSender,
6362    ) -> (
6363        UnboundedSender<(DestinationRouting, ApplicationDataOut)>,
6364        tokio::task::JoinHandle<anyhow::Result<()>>,
6365    ) {
6366        let (tx, rx) = futures::channel::mpsc::unbounded();
6367        let handle = tokio::task::spawn(async move {
6368            pin_mut!(rx);
6369            while let Some((routing, data)) = rx.next().await {
6370                sender.send_message(routing, data).await?;
6371            }
6372            Ok(())
6373        });
6374        (tx, handle)
6375    }
6376
6377    fn msg_type(data: &ApplicationDataOut, expected: StartProtocolDiscriminants) -> bool {
6378        HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
6379            .map(|d| StartProtocolDiscriminants::from(d) == expected)
6380            .unwrap_or(false)
6381    }
6382
6383    fn start_msg_match(data: &ApplicationDataOut, msg: impl Fn(HoprStartProtocol) -> bool) -> bool {
6384        HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
6385            .map(msg)
6386            .unwrap_or(false)
6387    }
6388
6389    /// Waits (bounded) until the manager reports no active sessions.
6390    ///
6391    /// The session-slot rollback runs on a spawned task, so its effect is observed
6392    /// asynchronously; this polls [`SessionManager::active_sessions`] until it drains.
6393    async fn wait_for_no_active_sessions(
6394        mgr: &SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>,
6395    ) -> bool {
6396        for _ in 0..50 {
6397            if mgr.active_sessions().is_empty() {
6398                return true;
6399            }
6400            tokio::time::sleep(Duration::from_millis(20)).await;
6401        }
6402        mgr.active_sessions().is_empty()
6403    }
6404
6405    const SESSION_FORWARD_CAPACITY: usize = 10000;
6406
6407    /// Verifies that a session's SURB balancer config can be retrieved and updated via the manager API.
6408    ///
6409    /// ## Steps
6410    /// 1. A session slot is manually inserted into Alice's manager with a known `SurbBalancerConfig`
6411    ///    (`target_surb_buffer_size: 1000`, `max_surbs_per_sec: 100`).
6412    /// 2. `get_surb_balancer_config` returns the config, confirming round-trip storage.
6413    /// 3. `update_surb_balancer_config` is called with a new config (`target: 2000`, `max: 200`).
6414    /// 4. `get_surb_balancer_config` is called again and the returned config matches the updated values.
6415    #[test_log::test(tokio::test)]
6416    async fn session_manager_should_update_surb_balancer_config() -> anyhow::Result<()> {
6417        let alice_pseudonym = HoprPseudonym::random();
6418        let session_id = alice_pseudonym;
6419        let balancer_cfg = SurbBalancerConfig {
6420            target_surb_buffer_size: 1000,
6421            max_surbs_per_sec: 100,
6422            ..Default::default()
6423        };
6424
6425        let alice_mgr =
6426            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
6427
6428        let (dummy_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
6429        alice_mgr.sessions.insert(
6430            session_id,
6431            SessionSlot {
6432                session_tx: dummy_tx,
6433                routing_opts: DestinationRouting::Return(SurbMatcher::Pseudonym(alice_pseudonym)),
6434                abort_handles: Default::default(),
6435                surb_mgmt: Arc::new(BalancerStateValues::from(balancer_cfg)),
6436                surb_estimator: Default::default(),
6437                current_ssa_state: Default::default(),
6438                pix_supervisor: Default::default(),
6439                pix_egress_gate: Default::default(),
6440                pix_fill: Default::default(),
6441                returned_packets: Default::default(),
6442                cycle_budget: None,
6443            },
6444        );
6445
6446        let actual_cfg = alice_mgr
6447            .get_surb_balancer_config(&session_id)?
6448            .ok_or(anyhow!("session must have a surb balancer config"))?;
6449        assert_eq!(actual_cfg, balancer_cfg);
6450
6451        let new_cfg = SurbBalancerConfig {
6452            target_surb_buffer_size: 2000,
6453            max_surbs_per_sec: 200,
6454            ..Default::default()
6455        };
6456        alice_mgr.update_surb_balancer_config(&session_id, new_cfg)?;
6457
6458        let actual_cfg = alice_mgr
6459            .get_surb_balancer_config(&session_id)?
6460            .ok_or(anyhow!("session must have a surb balancer config"))?;
6461        assert_eq!(actual_cfg, new_cfg);
6462
6463        Ok(())
6464    }
6465
6466    /// Verifies that a self-initiated session is rejected with `SessionManagerError::Loopback`.
6467    ///
6468    /// ## Steps
6469    /// 1. Alice's manager is started with a mock transport that delivers messages back to itself.
6470    /// 2. Alice initiates a session toward `bob_peer`; the mock routes her `StartSession` back to her own manager
6471    ///    (simulating a network loopback).
6472    /// 3. Alice's manager processes `StartSession` as incoming, auto-responds with `SessionEstablished`, and the mock
6473    ///    delivers it back to complete the handshake.
6474    /// 4. `new_session` returns `Err(TransportSessionError::Manager(SessionManagerError::Loopback))`.
6475    /// 5. Exactly one active session is present — the incoming slot accepted from the self-delivered `StartSession`.
6476    ///    The rejection fires after slot insertion, not before.
6477    #[test_log::test(tokio::test)]
6478    async fn session_manager_should_not_allow_loopback_sessions() -> anyhow::Result<()> {
6479        let alice_pseudonym = HoprPseudonym::random();
6480        let bob_peer: Address = (&ChainKeypair::random()).into();
6481
6482        let alice_mgr = SessionManager::new(Default::default());
6483
6484        let mut sequence = mockall::Sequence::new();
6485        let mut alice_transport = MockMsgSender::new();
6486
6487        // Alice sends the StartSession message
6488        let alice_mgr_clone = alice_mgr.clone();
6489        alice_transport
6490            .expect_send_message()
6491            .once()
6492            .in_sequence(&mut sequence)
6493            .withf(move |peer, data| {
6494                msg_type(data, StartProtocolDiscriminants::StartSession)
6495                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
6496            })
6497            .returning(move |_, data| {
6498                // But the message is again processed by Alice due to Loopback
6499                let alice_mgr_clone = alice_mgr_clone.clone();
6500                Box::pin(async move {
6501                    alice_mgr_clone
6502                        .dispatch_message(
6503                            alice_pseudonym,
6504                            ApplicationDataIn {
6505                                data: data.data,
6506                                packet_info: Default::default(),
6507                            },
6508                        )
6509                        ?;
6510                    Ok(())
6511                })
6512            });
6513
6514        // Alice sends the SessionEstablished message (as Bob)
6515        let alice_mgr_clone = alice_mgr.clone();
6516        alice_transport
6517            .expect_send_message()
6518            .once()
6519            .in_sequence(&mut sequence)
6520            .withf(move |peer, data| {
6521                msg_type(data, StartProtocolDiscriminants::SessionEstablished)
6522                    && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if *p == alice_pseudonym)
6523            })
6524            .returning(move |_, data| {
6525                let alice_mgr_clone = alice_mgr_clone.clone();
6526
6527                Box::pin(async move {
6528                    alice_mgr_clone.dispatch_message(
6529                        alice_pseudonym,
6530                        ApplicationDataIn {
6531                            data: data.data,
6532                            packet_info: Default::default(),
6533                        },
6534                    )?;
6535                    Ok(())
6536                })
6537            });
6538
6539        // Start Alice
6540        let (new_session_tx_alice, new_session_rx_alice) = futures::channel::mpsc::channel(1024);
6541        let (alice_sender, alice_handle) = mock_packet_planning(alice_transport);
6542        alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?;
6543        assert!(alice_mgr.is_started());
6544
6545        let alice_session = alice_mgr
6546            .new_session(
6547                bob_peer,
6548                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6549                SessionClientConfig {
6550                    capabilities: None.into(),
6551                    pseudonym: alice_pseudonym.into(),
6552                    surb_management: None,
6553                    ..Default::default()
6554                },
6555            )
6556            .await;
6557
6558        println!("{alice_session:?}");
6559        assert!(matches!(
6560            alice_session,
6561            Err(TransportSessionError::Manager(SessionManagerError::Loopback))
6562        ));
6563        // There is one session in the manager, which is the incoming one that Alice's manager
6564        // accepted when it received the StartSession message from itself.
6565        assert_eq!(alice_mgr.num_active_sessions(), 1);
6566
6567        drop(new_session_rx_alice);
6568
6569        // Cleanup: close sender and await handle
6570        alice_sender.close_channel();
6571        alice_handle.await??;
6572
6573        Ok(())
6574    }
6575
6576    /// Verifies that a session initiation returns `TransportSessionError::Timeout` when the peer
6577    /// never processes or responds to the `StartSession` message.
6578    ///
6579    /// ## Steps
6580    /// 1. Alice's manager is configured with `initiation_timeout_base: 100ms`. Bob's manager is started but its mock
6581    ///    transport silently swallows all messages (never dispatches to the manager).
6582    /// 2. Alice calls `new_session`; her `StartSession` is captured by the mock and silently discarded.
6583    /// 3. The 100ms timeout expires; `new_session` returns `Err(TransportSessionError::Timeout)`.
6584    /// 4. `num_active_sessions` is 0, confirming no orphaned slot was left in the cache.
6585    #[test_log::test(tokio::test)]
6586    async fn session_manager_should_timeout_new_session_attempt_when_no_response() -> anyhow::Result<()> {
6587        let bob_peer: Address = (&ChainKeypair::random()).into();
6588
6589        let cfg = SessionManagerConfig {
6590            initiation_timeout_base: Duration::from_millis(100),
6591            ..Default::default()
6592        };
6593
6594        let alice_mgr = SessionManager::new(cfg);
6595        let bob_mgr = SessionManager::new(Default::default());
6596
6597        let mut sequence = mockall::Sequence::new();
6598        let mut alice_transport = MockMsgSender::new();
6599        let bob_transport = MockMsgSender::new();
6600
6601        // Alice sends the StartSession message, but Bob does not handle it
6602        alice_transport
6603            .expect_send_message()
6604            .once()
6605            .in_sequence(&mut sequence)
6606            .withf(move |peer, data| {
6607                msg_type(data, StartProtocolDiscriminants::StartSession)
6608                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
6609            })
6610            .returning(|_, _| Box::pin(async { Ok(()) }));
6611
6612        // Start Alice
6613        let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
6614        let (alice_sender, _alice_handle) = mock_packet_planning(alice_transport);
6615        alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?;
6616        assert!(alice_mgr.is_started());
6617
6618        // Start Bob
6619        let (new_session_tx_bob, _) = futures::channel::mpsc::channel(1024);
6620        let (bob_sender, _bob_handle) = mock_packet_planning(bob_transport);
6621        bob_mgr.start(bob_sender.clone(), new_session_tx_bob, None, None)?;
6622        assert!(bob_mgr.is_started());
6623
6624        let result = alice_mgr
6625            .new_session(
6626                bob_peer,
6627                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6628                SessionClientConfig {
6629                    capabilities: None.into(),
6630                    pseudonym: None,
6631                    surb_management: None,
6632                    ..Default::default()
6633                },
6634            )
6635            .await;
6636
6637        assert!(matches!(result, Err(TransportSessionError::Timeout)));
6638        assert_eq!(alice_mgr.num_active_sessions(), 0);
6639
6640        Ok(())
6641    }
6642
6643    /// Verifies that a failed incoming session establishment does not register any telemetry.
6644    ///
6645    /// ## Steps
6646    /// 1. A `SessionManager` is started with the `telemetry` feature enabled.
6647    /// 2. The new-session notification channel's receiver is dropped immediately, so notifying about a new incoming
6648    ///    session will fail.
6649    /// 3. `handle_incoming_session_initiation` is called with a random pseudonym. The slot is inserted first, then
6650    ///    notifying about the new session fails (receiver is gone).
6651    /// 4. `wait_for_no_active_sessions` polls until there are no active sessions, confirming the partially-inserted
6652    ///    slot was rolled back.
6653    /// 5. `num_active_sessions` is 0, proving the rollback prevented any telemetry registration for the failed session.
6654    #[cfg(feature = "telemetry")]
6655    #[test_log::test(tokio::test)]
6656    async fn failed_incoming_session_establishment_does_not_register_telemetry() -> anyhow::Result<()> {
6657        let mgr = SessionManager::new(Default::default());
6658
6659        let transport = MockMsgSender::new();
6660        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
6661        drop(new_session_rx);
6662        let (sender, _handle) = mock_packet_planning(transport);
6663        mgr.start(sender.clone(), new_session_tx, None, None)?;
6664        assert!(mgr.is_started());
6665
6666        let pseudonym = HoprPseudonym::random();
6667        let result = mgr
6668            .handle_incoming_session_initiation(
6669                pseudonym,
6670                StartInitiation {
6671                    challenge: MIN_CHALLENGE,
6672                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6673                    capabilities: HoprSessionCapabilities(Capabilities::empty()),
6674                    additional_data: 0,
6675                },
6676            )
6677            .await;
6678
6679        assert!(result.is_err());
6680
6681        // The slot inserted before the failure must be rolled back, so it neither
6682        // counts towards `maximum_sessions` nor registers any telemetry.
6683        assert!(
6684            wait_for_no_active_sessions(&mgr).await,
6685            "the partially established session slot was not rolled back"
6686        );
6687        assert_eq!(mgr.num_active_sessions(), 0);
6688
6689        // Cleanup: close sender and await handle
6690        sender.close_channel();
6691        _handle.await??;
6692
6693        Ok(())
6694    }
6695
6696    /// Verifies that a session slot is rolled back if session setup fails after the slot is inserted.
6697    ///
6698    /// ## Steps
6699    /// 1. A `SessionManager` is started; the new-session notification channel's receiver is dropped, so notifying about
6700    ///    a new incoming session will fail.
6701    /// 2. `handle_incoming_session_initiation` is called with a random pseudonym. The slot is inserted into the cache
6702    ///    first, then notifying about the new session fails (because the receiver is gone).
6703    /// 3. The call returns an error, and `wait_for_no_active_sessions` confirms the slot was removed.
6704    /// 4. `num_active_sessions` is 0, proving the rollback removed the slot and freed the pseudonym.
6705    #[test_log::test(tokio::test)]
6706    async fn session_manager_should_roll_back_slot_when_incoming_session_setup_fails() -> anyhow::Result<()> {
6707        let mgr = SessionManager::new(Default::default());
6708
6709        // Drop the receiver so that notifying about the new incoming session fails
6710        // *after* the session slot has already been inserted into the cache.
6711        let transport = MockMsgSender::new();
6712        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
6713        drop(new_session_rx);
6714        let (sender, handle) = mock_packet_planning(transport);
6715        mgr.start(sender.clone(), new_session_tx, None, None)?;
6716        assert!(mgr.is_started());
6717
6718        let pseudonym = HoprPseudonym::random();
6719
6720        // The setup fails after the slot is inserted (notifying about the new
6721        // incoming session errors out because the receiver is gone), so the slot
6722        // must be rolled back instead of lingering until idle eviction.
6723        let result = mgr
6724            .handle_incoming_session_initiation(
6725                pseudonym,
6726                StartInitiation {
6727                    challenge: MIN_CHALLENGE,
6728                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6729                    capabilities: HoprSessionCapabilities::empty(),
6730                    additional_data: 0,
6731                },
6732            )
6733            .await;
6734        assert!(result.is_err());
6735
6736        // An empty active-session set proves the slot was removed and, since
6737        // sessions are keyed by pseudonym, that the pseudonym is free again.
6738        assert!(
6739            wait_for_no_active_sessions(&mgr).await,
6740            "the partially established session slot was not rolled back"
6741        );
6742
6743        // Cleanup
6744        sender.close_channel();
6745        handle.await??;
6746
6747        Ok(())
6748    }
6749
6750    /// Collects everything that arrives on `rx` during `window`, returning once it elapses.
6751    async fn originated_during(
6752        rx: &mut futures::channel::mpsc::UnboundedReceiver<(DestinationRouting, ApplicationDataOut)>,
6753        window: Duration,
6754    ) -> Vec<(DestinationRouting, ApplicationDataOut)> {
6755        let mut collected = Vec::new();
6756        let deadline = tokio::time::Instant::now() + window;
6757        while let Ok(Some(item)) = timeout(
6758            deadline.saturating_duration_since(tokio::time::Instant::now()),
6759            rx.next(),
6760        )
6761        .await
6762        {
6763            collected.push(item);
6764        }
6765        collected
6766    }
6767
6768    /// The manager floors the keep-alive period at [`MIN_SURB_BUFFER_NOTIFICATION_PERIOD`], so this
6769    /// is as fast as an Exit keep-alive can be made to run, and it sets the pace of these tests.
6770    const KEEP_ALIVE_PERIOD: Duration = MIN_SURB_BUFFER_NOTIFICATION_PERIOD;
6771
6772    type RecordingManager = SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>;
6773    type Originated = futures::channel::mpsc::UnboundedReceiver<(DestinationRouting, ApplicationDataOut)>;
6774
6775    /// Brings up an Exit-side session and returns once its keep-alive stream is observably running.
6776    ///
6777    /// The "observably running" part is load-bearing for every caller: `nothing was originated` is
6778    /// equally true of a stream that stopped and one that never started, so a test that does not
6779    /// first establish the stream is alive proves nothing when it later sees silence.
6780    async fn exit_session_originating_keep_alives(
6781        cfg: SessionManagerConfig,
6782    ) -> anyhow::Result<(RecordingManager, Originated, HoprPseudonym)> {
6783        let mgr = RecordingManager::new(SessionManagerConfig {
6784            surb_balance_notify_period: Some(KEEP_ALIVE_PERIOD),
6785            ..cfg
6786        });
6787
6788        let (msg_tx, mut msg_rx) = futures::channel::mpsc::unbounded();
6789        // Held, not dropped: dropping it would fail the establishment notification instead.
6790        let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(4);
6791        mgr.start(msg_tx, new_session_tx, None, None)?;
6792
6793        let pseudonym = HoprPseudonym::random();
6794        mgr.handle_incoming_session_initiation(
6795            pseudonym,
6796            StartInitiation {
6797                challenge: MIN_CHALLENGE,
6798                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6799                // Empty capabilities keep rate control on, which is what spawns the balancer and
6800                // the keep-alive stream. `NoRateControl` would skip both and make this vacuous.
6801                capabilities: HoprSessionCapabilities(Capabilities::empty()),
6802                additional_data: 0,
6803            },
6804        )
6805        .await?;
6806
6807        let observed = originated_during(&mut msg_rx, KEEP_ALIVE_PERIOD * 2 + Duration::from_millis(500)).await;
6808        let keep_alives = observed
6809            .iter()
6810            .filter(|(routing, data)| {
6811                msg_type(data, StartProtocolDiscriminants::KeepAlive)
6812                    && matches!(routing, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if p == &pseudonym)
6813            })
6814            .count();
6815        anyhow::ensure!(
6816            keep_alives > 0,
6817            "no return-routed keep-alive was originated, so this test cannot tell a stopped stream from one that \
6818             never ran; {} message(s) were observed in total",
6819            observed.len()
6820        );
6821
6822        Ok((mgr, msg_rx, pseudonym))
6823    }
6824
6825    /// Asserts `mgr` originates nothing further for `pseudonym`.
6826    ///
6827    /// Packets already handed to the sender before the closure are in flight rather than newly
6828    /// originated, so they are drained first and only what appears afterwards counts.
6829    async fn assert_no_further_origination(rx: &mut Originated, closure: &str) {
6830        let _in_flight = originated_during(rx, Duration::from_millis(200)).await;
6831
6832        let window = KEEP_ALIVE_PERIOD * 3;
6833        let after = originated_during(rx, window).await;
6834        assert!(
6835            after.is_empty(),
6836            "an Exit session closed by {closure} originated {} further packet(s) over {window:?} — each one is \
6837             return-routed to a pseudonym whose SURBs are gone, and one such packet is enough to stall all \
6838             origination on the node",
6839            after.len()
6840        );
6841    }
6842
6843    /// An Exit session must originate nothing once it has been closed explicitly.
6844    ///
6845    /// The Exit's keep-alive stream is a `repeat_with` on a rate limiter: it produces a
6846    /// return-routed packet every period regardless of whether a SURB exists to carry it. That is
6847    /// fine while the initiator is present and replenishing, and it is the *supply* side of the
6848    /// `london-01` outage once the initiator is gone — every such packet is one the routing
6849    /// resolution stage can never resolve, and one unresolvable packet there stalls all origination
6850    /// on the node (see `hopr_transport::path::resolve`).
6851    ///
6852    /// Teardown is therefore the only thing standing between a departed initiator and an unbounded
6853    /// supply of unresolvable packets, which is why each closure path has to be shown to stop the
6854    /// stream rather than merely drop the slot.
6855    #[test_log::test(tokio::test)]
6856    async fn an_exit_session_should_originate_nothing_after_an_explicit_close() -> anyhow::Result<()> {
6857        // Default idle timeout (180 s), so eviction cannot confound what the explicit close proves.
6858        let (mgr, mut msg_rx, pseudonym) = exit_session_originating_keep_alives(Default::default()).await?;
6859
6860        assert!(mgr.close_session(&pseudonym), "the session must exist to be closed");
6861
6862        assert_no_further_origination(&mut msg_rx, "an explicit close").await;
6863        Ok(())
6864    }
6865
6866    /// An Exit session must originate nothing once it has been evicted for being idle.
6867    ///
6868    /// This is the path that matters most for a departed initiator: nobody closes that session, so
6869    /// idle eviction is what ends it, and eviction runs through a Moka listener rather than the
6870    /// explicit close path. A keep-alive stream that survives eviction would go on originating
6871    /// unresolvable return packets with no session left to account for them.
6872    #[test_log::test(tokio::test)]
6873    async fn an_exit_session_should_originate_nothing_after_idle_eviction() -> anyhow::Result<()> {
6874        let idle_timeout = KEEP_ALIVE_PERIOD * 3;
6875        let (mgr, mut msg_rx, _) = exit_session_originating_keep_alives(SessionManagerConfig {
6876            idle_timeout,
6877            ..Default::default()
6878        })
6879        .await?;
6880
6881        // Moka evicts lazily, so drive its maintenance rather than waiting for the manager's own
6882        // (jittered, multi-second) eviction tick: this makes the eviction prompt and deterministic.
6883        for _ in 0..50 {
6884            mgr.sessions.run_pending_tasks();
6885            if mgr.active_sessions().is_empty() {
6886                break;
6887            }
6888            tokio::time::sleep(idle_timeout / 10).await;
6889        }
6890        assert!(
6891            mgr.active_sessions().is_empty(),
6892            "the idle session was never evicted, so this test cannot say anything about eviction"
6893        );
6894
6895        assert_no_further_origination(&mut msg_rx, "idle eviction").await;
6896        Ok(())
6897    }
6898
6899    // ---------------------------------------------------------------
6900    // PIX fill — the Exit's own keep-alives
6901    // ---------------------------------------------------------------
6902
6903    /// A [`PixFillControl`] over a random pseudonym and an empty SURB estimate.
6904    ///
6905    /// `reserve` is the *effective* reserve, as [`effective_surb_reserve`] would have derived it —
6906    /// these tests are about what `admit` does with one, not about where it came from. Against an
6907    /// estimate that starts empty, any `reserve` above zero refuses fill outright, which is what makes
6908    /// admission a usable assertion that a packet was classified as the notification.
6909    fn fill_control(notify_period: Option<Duration>, reserve: u64) -> PixFillControl {
6910        PixFillControl::new(
6911            HoprPseudonym::random(),
6912            RateController::new(0, Duration::from_secs(1)),
6913            notify_period,
6914            reserve,
6915            Default::default(),
6916        )
6917    }
6918
6919    /// The `surb_starved` gauge moves on the edges, and a control dropped mid-stall returns it.
6920    ///
6921    /// Per refused packet would measure how hard the stream retried rather than how long the
6922    /// Session was starved, and a control that went away while withholding would leave the count
6923    /// standing for the life of the process.
6924    #[test]
6925    fn the_surb_starved_gauge_counts_stalls_and_is_returned_on_drop() {
6926        use crate::supervision::telemetry::probe;
6927
6928        probe::reset();
6929        let control = fill_control(None, 10);
6930        let now = Instant::now();
6931
6932        // The estimator starts empty, so every one of these is below the reserve of ten.
6933        for _ in 0..3 {
6934            assert!(!control.admit_at(now), "fill must be withheld below the reserve");
6935        }
6936        assert_eq!(
6937            1,
6938            probe::get("sessions_stalled/surb_starved"),
6939            "three refusals are one stall, not three"
6940        );
6941
6942        // Enough SURBs arrive to clear the reserve.
6943        control
6944            .estimator
6945            .produced
6946            .fetch_add(10, std::sync::atomic::Ordering::Relaxed);
6947        assert!(control.admit_at(now));
6948        assert_eq!(0, probe::get("sessions_stalled/surb_starved"));
6949
6950        // And a control dropped while withholding returns its own.
6951        control
6952            .estimator
6953            .consumed
6954            .fetch_add(10, std::sync::atomic::Ordering::Relaxed);
6955        assert!(!control.admit_at(now));
6956        assert_eq!(1, probe::get("sessions_stalled/surb_starved"));
6957        drop(control);
6958        assert_eq!(
6959            0,
6960            probe::get("sessions_stalled/surb_starved"),
6961            "a session that dies mid-stall must not leave the gauge standing"
6962        );
6963    }
6964
6965    /// The fill reserve reads the estimator's held count, which must not stay inflated by evicted
6966    /// SURBs. After an overflow drains, the real store is empty, yet a raw `produced - consumed` would
6967    /// still read far above the reserve and admit fill into an empty buffer -- the exact failure the
6968    /// reserve exists to prevent (a return packet that finds no SURB stalls every packet the node
6969    /// originates). With evictions counted, the held count is what the store holds, so fill is withheld.
6970    #[test]
6971    fn the_fill_reserve_is_not_defeated_by_a_post_overflow_estimate() {
6972        use std::sync::atomic::Ordering;
6973
6974        const RESERVE: u64 = 100;
6975        let control = fill_control(None, RESERVE);
6976        let now = Instant::now();
6977
6978        // Post-overflow-drain: 1000 SURBs arrived, the full store evicted 900 of them, and the 100 it
6979        // actually held were all spent. The store is empty; `produced - consumed` alone reads 900.
6980        control.estimator.produced.fetch_add(1000, Ordering::Relaxed);
6981        control.estimator.evicted.fetch_add(900, Ordering::Relaxed);
6982        control.estimator.consumed.fetch_add(100, Ordering::Relaxed);
6983
6984        assert_eq!(0, control.drainable_surbs(), "an empty store has nothing to drain");
6985        assert!(
6986            !control.admit_at(now),
6987            "fill must be withheld: the real held count is below the reserve"
6988        );
6989    }
6990
6991    /// One stream, two producers, and the faster of them wins.
6992    ///
6993    /// The SURB-level notification and PIX fill are the same packet sent for different reasons, so
6994    /// running two streams of them would double the Exit's return traffic to say the same thing
6995    /// twice. What has to be preserved is the floor: turning fill off must leave the notification
6996    /// running, and a Session with no notification configured must still be fillable — that second
6997    /// case is the deployment's own test fixtures, which pin `surb_balance_notify_period: None`.
6998    #[test]
6999    fn the_exit_keep_alive_rate_is_the_faster_of_its_two_producers() {
7000        let notify = Duration::from_secs(60);
7001        let control = fill_control(Some(notify), 1);
7002
7003        assert_eq!(
7004            FillRate::ZERO,
7005            control.effective_rate(),
7006            "nothing runs before the notification's initial delay has elapsed"
7007        );
7008
7009        control.start_notify();
7010        assert_eq!(FillRate::once_per(notify), control.effective_rate());
7011
7012        control.set_fill(FillRate::per_second(10));
7013        assert_eq!(
7014            FillRate::per_second(10),
7015            control.effective_rate(),
7016            "fill above the notification rate must take over"
7017        );
7018
7019        control.set_fill(FillRate::once_per(Duration::from_secs(600)));
7020        assert_eq!(
7021            FillRate::once_per(notify),
7022            control.effective_rate(),
7023            "fill below the notification rate must not slow it down"
7024        );
7025
7026        control.set_fill(FillRate::ZERO);
7027        assert_eq!(
7028            FillRate::once_per(notify),
7029            control.effective_rate(),
7030            "a Session that has stopped filling still reports its SURB level"
7031        );
7032
7033        // And the other way round: no notification configured, so fill is the only producer.
7034        let fill_only = fill_control(None, 1);
7035        fill_only.start_notify();
7036        assert_eq!(FillRate::ZERO, fill_only.effective_rate());
7037        fill_only.set_fill(FillRate::per_second(4));
7038        assert_eq!(FillRate::per_second(4), fill_only.effective_rate());
7039        fill_only.set_fill(FillRate::ZERO);
7040        assert_eq!(FillRate::ZERO, fill_only.effective_rate());
7041    }
7042
7043    /// Fill yields to the Session's own SURB reserve; the SURB-level notification does not.
7044    ///
7045    /// The asymmetry is the whole rule. A return packet that finds no SURB is not dropped — the
7046    /// routing resolver holds it, and every packet this node originates behind it, for the whole of
7047    /// `surb_resolution_wait`. So an Exit that filled a cycle down to its last SURB would stall the
7048    /// Session it was filling for, and every other Session on the node with it. The notification is
7049    /// exempt because it is the message that *asks* for more SURBs: refusing to send it when SURBs
7050    /// are scarce is refusing to ask for the thing that is scarce.
7051    #[test]
7052    fn fill_withholds_below_the_surb_reserve_while_the_level_notification_does_not() {
7053        const RESERVE: u64 = 100;
7054        let control = fill_control(Some(Duration::from_secs(3600)), RESERVE);
7055        control.start_notify();
7056
7057        // The first packet is the notification, whatever the estimate says.
7058        assert!(control.admit(), "the level notification must go out on an empty buffer");
7059        // The notification period is an hour, so everything after it is fill — and there is nothing
7060        // to spend.
7061        assert!(!control.admit(), "fill must not spend a SURB the Session does not have");
7062
7063        control
7064            .estimator
7065            .produced
7066            .fetch_add(RESERVE - 1, std::sync::atomic::Ordering::Relaxed);
7067        assert!(!control.admit(), "one short of the reserve is still short");
7068
7069        control
7070            .estimator
7071            .produced
7072            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7073        assert!(control.admit(), "at the reserve, fill may spend");
7074
7075        // Consumption counted elsewhere walks the estimate back below the reserve.
7076        control
7077            .estimator
7078            .consumed
7079            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7080        assert!(!control.admit());
7081    }
7082
7083    /// Exactly one caller may take a Session into its drain, and doing so silences the notification.
7084    ///
7085    /// `begin_surb_drain` is reached from the operator's close, the closure notifier and
7086    /// `handle_session_error` independently, so two closes can both read [`is_draining`] as false and
7087    /// race for the same drain. The transition is what settles it: the winner is the one caller that
7088    /// tells the supervisor the Session closed, and the loser is answered as though it had arrived
7089    /// afterwards. Were the check and the write separate, both would start a drain and the supervisor
7090    /// would be told twice about one close.
7091    ///
7092    /// [`is_draining`]: PixFillControl::is_draining
7093    #[test]
7094    fn only_one_caller_can_take_a_session_into_its_drain() {
7095        let notify = Duration::from_secs(60);
7096        let control = fill_control(Some(notify), 1);
7097        control.start_notify();
7098        assert_eq!(FillRate::once_per(notify), control.effective_rate());
7099
7100        assert!(control.enter_drain(), "the first caller takes the drain");
7101        assert!(control.is_draining());
7102        assert!(
7103            !control.enter_drain(),
7104            "a concurrent close must lose the transition rather than start a second drain"
7105        );
7106        assert!(control.is_draining(), "losing the transition must not clear the drain");
7107
7108        assert_eq!(
7109            FillRate::ZERO,
7110            control.effective_rate(),
7111            "a draining stream runs at the fill rate alone, and nothing is filling it"
7112        );
7113        assert!(
7114            !control.admit(),
7115            "the notification is the one packet exempt from the reserve, and a drain silences it"
7116        );
7117
7118        // Nor can the one-shot delay task, which holds a clone of this control, turn it back on.
7119        control.start_notify();
7120        assert_eq!(FillRate::ZERO, control.effective_rate());
7121    }
7122
7123    /// The SURB-level notification is classified by when it was *due*, not by when the scheduler got
7124    /// round to releasing it.
7125    ///
7126    /// The rate limiter that releases these packets does not hit the period exactly: it sleeps in
7127    /// chunks of `MAX_WAIT_CHUNK`, re-reads its controller on each wake, and rounds a rate into an
7128    /// integer packet count. Compared against the period exactly, a period the controller rounds
7129    /// short by a microsecond would classify every other notification as fill — and, below the
7130    /// reserve, drop it. That is the message that *asks* for the SURBs whose absence caused the drop,
7131    /// and it runs on every Exit Session with a notification period configured, PIX or not.
7132    ///
7133    /// The reserve here is the instrument: the estimator is empty, so a packet the rule classifies as
7134    /// fill is refused and a packet it classifies as the notification is admitted. Admission is
7135    /// therefore the assertion that it was not counted as fill.
7136    #[test]
7137    fn a_level_notification_released_a_shade_early_is_still_the_notification() {
7138        const RESERVE: u64 = 100;
7139        // Two seconds, so the slack is `MAX_WAIT_CHUNK` rather than half the period, and the two
7140        // bounds are distinguishable.
7141        let period = Duration::from_secs(2);
7142        let control = fill_control(Some(period), RESERVE);
7143        control.start_notify();
7144
7145        let t0 = Instant::now();
7146        assert!(
7147            control.admit_at(t0),
7148            "the first packet is the notification, whatever the estimate says"
7149        );
7150
7151        // A shade under the period — the shape a rate rounded down by a microsecond produces.
7152        let early = t0 + period - Duration::from_micros(1);
7153        assert!(
7154            control.admit_at(early),
7155            "a notification released a microsecond early must not be reclassified as fill and dropped"
7156        );
7157
7158        // And it is the notification rather than a free pass: the next packet is measured from when
7159        // this one actually went out, so everything inside the following period is fill.
7160        assert!(
7161            !control.admit_at(early + Duration::from_millis(1)),
7162            "the packet after the notification is fill, and there is nothing to spend"
7163        );
7164        assert!(
7165            !control.admit_at(early + period - MAX_WAIT_CHUNK - Duration::from_millis(1)),
7166            "a packet inside the slack window is still fill"
7167        );
7168
7169        // The slack window opens `MAX_WAIT_CHUNK` before the period is up, and not earlier.
7170        assert!(
7171            control.admit_at(early + period - MAX_WAIT_CHUNK),
7172            "at the edge of the slack window the packet is the notification again"
7173        );
7174
7175        // A period short enough that `MAX_WAIT_CHUNK` would swallow it keeps half of itself.
7176        let short = Duration::from_millis(200);
7177        let control = fill_control(Some(short), RESERVE);
7178        control.start_notify();
7179        let t0 = Instant::now();
7180        assert!(control.admit_at(t0));
7181        assert!(
7182            !control.admit_at(t0 + short / 2 - Duration::from_millis(1)),
7183            "the slack is capped at half the period, so it cannot widen a short period away to nothing"
7184        );
7185        assert!(control.admit_at(t0 + short / 2));
7186    }
7187
7188    /// The reserve is a ceiling on a figure derived from the buffer the *Entry* asked for.
7189    ///
7190    /// The configured value is an absolute SURB count, while the buffer it is compared against is
7191    /// sized by the peer. Left absolute, a PIX Session whose Entry targets fewer SURBs than the
7192    /// reserve could never be filled at all — every fill packet withheld, the funded cycle stranded on
7193    /// `max_recovery_time`, and nothing in the logs but a backoff.
7194    #[test]
7195    fn the_fill_surb_reserve_is_capped_at_a_quarter_of_the_entrys_announced_buffer() {
7196        // The shipped reserve against the worked profile's buffer: the configured value binds and the
7197        // derivation is inert, which is what keeps this from quietly weakening production.
7198        assert_eq!(500, effective_surb_reserve(500, 7_000));
7199        // Exactly four times the reserve is the point at which the two agree.
7200        assert_eq!(500, effective_surb_reserve(500, 2_000));
7201
7202        // Below that, the Entry's buffer is what decides. The cluster fixtures' 64-SURB target is the
7203        // case this exists for.
7204        assert_eq!(16, effective_surb_reserve(500, 64));
7205        assert_eq!(12, effective_surb_reserve(500, 50));
7206
7207        // Never zero: an Entry announcing a buffer of three would otherwise let fill spend the
7208        // Session's last SURB, which is the one failure the reserve exists to prevent.
7209        assert_eq!(1, effective_surb_reserve(500, 3));
7210        assert_eq!(1, effective_surb_reserve(500, 0));
7211        assert_eq!(1, effective_surb_reserve(0, 7_000));
7212    }
7213
7214    /// Test dimensions whose cycle is large enough to need a visible fill rate.
7215    ///
7216    /// [`small_pix_params`] is six packets, which any deadline covers at the heartbeat; these are
7217    /// three hundred, which against the fixture's 40 s recovery budget needs about eleven packets a
7218    /// second — far enough above the one-per-second notification for the two to be told apart.
7219    fn fill_pix_params() -> PixParams {
7220        PixParams::try_new(100, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE).expect("test dimensions must be valid")
7221    }
7222
7223    /// Brings up an Exit-side PIX Session, funds its first cycle, and returns once it is recovering.
7224    ///
7225    /// The Entry half is not simulated: the supervisor is a state machine, and driving it through
7226    /// `SsaRequestSent`, `CommitmentVerified` and `DepositConfirmed` puts its cycle in exactly the
7227    /// state a real commitment and deposit would — which is the state fill is planned from. What is
7228    /// real here is everything downstream of that: the action driver, the rate controller, the
7229    /// admission predicate and the stream.
7230    async fn recovering_exit_pix_session(
7231        capabilities: Capabilities,
7232        notify_period: Option<Duration>,
7233        fill: crate::supervision::PixFillConfig,
7234    ) -> anyhow::Result<(RecordingManager, Originated, HoprPseudonym)> {
7235        let params = fill_pix_params();
7236        let mgr = RecordingManager::new(SessionManagerConfig {
7237            surb_balance_notify_period: notify_period,
7238            pix_config: IncomingSessionPixConfig {
7239                quota_range: 0..=pix_params_to_quota(&params),
7240                supervision: SupervisorConfig {
7241                    // As short as a legal pair gets: the idle deadline must clear the reconstructor's
7242                    // 30 s acknowledgement window, and the hard deadline must exceed the idle one.
7243                    // That is what makes the required fill rate — a whole cycle over 75 % of 40 s,
7244                    // about eleven packets a second — observable inside a test-length window.
7245                    max_recovery_idle: Duration::from_secs(31),
7246                    max_recovery_time: Duration::from_secs(40),
7247                    fill,
7248                    ..Default::default()
7249                },
7250                ..Default::default()
7251            },
7252            ..Default::default()
7253        });
7254
7255        let (msg_tx, msg_rx) = futures::channel::mpsc::unbounded();
7256        let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(4);
7257        mgr.start(
7258            msg_tx,
7259            new_session_tx,
7260            Some(pix_toolbox_with_pool(
7261                Arc::new(SsaShareGenerator::new(SsaGeneratorConfig {
7262                    polynomials_per_ssa: params.polys_per_ssa(),
7263                    threshold: params.shares_per_poly(),
7264                    surplus_shares: params.surplus_shares(),
7265                })),
7266                SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
7267            )),
7268            None,
7269        )?;
7270
7271        let pseudonym = HoprPseudonym::random();
7272        mgr.handle_incoming_session_initiation(
7273            pseudonym,
7274            StartInitiation {
7275                challenge: MIN_CHALLENGE,
7276                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
7277                capabilities: HoprSessionCapabilities(capabilities | Capability::UsePIX),
7278                additional_data: into_initiation_additional_data(params.into_additional_data(0)),
7279            },
7280        )
7281        .await?;
7282
7283        let slot = mgr.sessions.get(&pseudonym).context("the session slot must exist")?;
7284        let supervisor = slot
7285            .pix_supervisor
7286            .get()
7287            .context("an Exit-side PIX session must have a supervisor")?;
7288
7289        let ssa_id = SsaId::new(pseudonym, SsaIndex::new(1).expect("index one is non-zero"));
7290        for event in [
7291            SessionPixEvent::SsaRequestSent(ssa_id),
7292            SessionPixEvent::CommitmentVerified(ssa_id),
7293            SessionPixEvent::DepositConfirmed {
7294                ssa_id,
7295                amount: hopr_api::HoprBalance::new_base(1000),
7296            },
7297        ] {
7298            supervisor
7299                .send_event(event)
7300                .await
7301                .map_err(|()| anyhow::anyhow!("the supervisor stopped accepting events"))?;
7302        }
7303
7304        Ok((mgr, msg_rx, pseudonym))
7305    }
7306
7307    /// Counts the return-routed keep-alives `mgr` originated for `pseudonym` during `window`.
7308    async fn keep_alives_during(rx: &mut Originated, pseudonym: HoprPseudonym, window: Duration) -> usize {
7309        originated_during(rx, window)
7310            .await
7311            .iter()
7312            .filter(|(routing, data)| {
7313                msg_type(data, StartProtocolDiscriminants::KeepAlive)
7314                    && matches!(routing, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if *p == pseudonym)
7315            })
7316            .count()
7317    }
7318
7319    /// A funded, idle PIX Session must be filled by the Exit at the rate its own deadline needs.
7320    ///
7321    /// This is the mechanism end to end on one node: the supervisor plans a rate, the action driver
7322    /// applies it to the shared keep-alive controller, the admission predicate lets the packets past,
7323    /// and they come out return-routed to the Entry. The floor asserted is deliberately the
7324    /// *notification* rate rather than the planned one — a regression that leaves the stream running
7325    /// at one packet a second is exactly the failure this exists to prevent, and it looks like
7326    /// success to any assertion that only checks the stream is alive.
7327    #[test_log::test(tokio::test)]
7328    async fn a_funded_idle_pix_session_is_filled_by_the_exit() -> anyhow::Result<()> {
7329        let notify = Duration::from_secs(1);
7330        let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7331            Capabilities::empty(),
7332            Some(notify),
7333            // Draining off, so the trailing close below still means "the stream stopped at once".
7334            // What a drain does instead is the subject of the tests further down.
7335            crate::supervision::PixFillConfig {
7336                min_surb_reserve: 1,
7337                drain_after_close: false,
7338                ..Default::default()
7339            },
7340        )
7341        .await?;
7342
7343        // SURBs to spend, so the reserve is not what this test is measuring.
7344        mgr.sessions
7345            .get(&pseudonym)
7346            .context("the session slot must exist")?
7347            .surb_estimator
7348            .produced
7349            .fetch_add(100_000, std::sync::atomic::Ordering::Relaxed);
7350
7351        // One sampling interval for the planner, then a window long enough that the notification
7352        // alone could not account for what is counted.
7353        let _ramp = originated_during(&mut msg_rx, Duration::from_millis(1500)).await;
7354        let window = Duration::from_millis(1500);
7355        let observed = keep_alives_during(&mut msg_rx, pseudonym, window).await;
7356
7357        // Counted against the rate the notification is *promoted* at rather than against the period,
7358        // for the reason `admit_at` documents: the classification window is one slack shorter than the
7359        // period, so a notification-only stream produces rather more than `window / period` packets.
7360        // Taking the period at face value here would set the floor too low and let a stream that had
7361        // stopped filling pass.
7362        let promote_every = notify - MAX_WAIT_CHUNK.min(notify / 2);
7363        let notify_only = (window.as_secs_f64() / promote_every.as_secs_f64()).ceil() as usize;
7364        assert!(
7365            observed > notify_only,
7366            "a funded idle cycle produced {observed} keep-alive(s) over {window:?}, which the {notify:?} SURB-level \
7367             notification alone could have produced ({notify_only}, one every {promote_every:?})"
7368        );
7369
7370        assert!(mgr.close_session(&pseudonym), "the session must exist to be closed");
7371        assert_no_further_origination(&mut msg_rx, "an explicit close of a filling PIX session").await;
7372        Ok(())
7373    }
7374
7375    // ---------------------------------------------------------------
7376    // Draining a closed PIX Session
7377    // ---------------------------------------------------------------
7378
7379    /// A funded, filling Exit PIX Session whose SURB estimate covers the rest of its cycle.
7380    ///
7381    /// The 100 000 is not decoration: it is the estimate `begin_surb_drain` nets the reserve out of
7382    /// and hands the supervisor, and against [`fill_pix_params`]' 300-packet cycle it is what makes a
7383    /// drain admissible at all. The ramp is waited out for the reason
7384    /// [`exit_session_originating_keep_alives`] gives — silence after a close says nothing unless the
7385    /// stream was observably running before it.
7386    async fn drainable_exit_pix_session(
7387        notify_period: Duration,
7388    ) -> anyhow::Result<(RecordingManager, Originated, HoprPseudonym)> {
7389        let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7390            Capabilities::empty(),
7391            Some(notify_period),
7392            crate::supervision::PixFillConfig {
7393                min_surb_reserve: 1,
7394                ..Default::default()
7395            },
7396        )
7397        .await?;
7398
7399        mgr.sessions
7400            .get(&pseudonym)
7401            .context("the session slot must exist")?
7402            .surb_estimator
7403            .produced
7404            .fetch_add(100_000, std::sync::atomic::Ordering::Relaxed);
7405
7406        let observed = keep_alives_during(&mut msg_rx, pseudonym, Duration::from_millis(1500)).await;
7407        anyhow::ensure!(
7408            observed > 0,
7409            "the fixture's keep-alive stream never ran, so nothing below can tell a drain from a Session that was \
7410             silent all along"
7411        );
7412        Ok((mgr, msg_rx, pseudonym))
7413    }
7414
7415    /// A closed PIX Session goes on originating until the cycle it was paid for recovers.
7416    ///
7417    /// The deposit is stranded rather than refunded if the cycle does not complete, so a close that
7418    /// tore the stream down while the Exit still held enough SURBs to finish would throw away money
7419    /// both sides have already paid. What must *not* happen is the mirror image of that: origination
7420    /// continuing past the point it is buying anything. So the recovery is delivered and the slot has
7421    /// to be gone, and the stream silent, on the other side of it.
7422    #[test_log::test(tokio::test)]
7423    async fn a_closed_pix_session_keeps_originating_until_its_cycle_recovers() -> anyhow::Result<()> {
7424        let notify = Duration::from_secs(1);
7425        let (mgr, mut msg_rx, pseudonym) = drainable_exit_pix_session(notify).await?;
7426
7427        assert!(
7428            mgr.close_session(&pseudonym),
7429            "a close that starts a drain is still a close that was handled"
7430        );
7431        assert!(
7432            mgr.active_sessions().contains(&pseudonym),
7433            "the slot must survive the close — the drain runs out of it, and the supervisor's own teardown is what \
7434             removes it"
7435        );
7436
7437        // The SURB-level notification is switched off for the whole drain, so every packet counted
7438        // here is fill. The bar is nevertheless the notification-only figure, which keeps this
7439        // comparable with `a_funded_idle_pix_session_is_filled_by_the_exit` above.
7440        let window = Duration::from_millis(1500);
7441        let observed = keep_alives_during(&mut msg_rx, pseudonym, window).await;
7442        let promote_every = notify - MAX_WAIT_CHUNK.min(notify / 2);
7443        let notify_only = (window.as_secs_f64() / promote_every.as_secs_f64()).ceil() as usize;
7444        assert!(
7445            observed > notify_only,
7446            "a closed session with a full SURB buffer originated {observed} keep-alive(s) over {window:?}, which is \
7447             no more than the {notify_only} its (now silenced) notification would have accounted for"
7448        );
7449
7450        // Recovery is the end of the drain: there is nothing left to work for, so the Session must go.
7451        let ssa_id = SsaId::new(pseudonym, SsaIndex::new(1).expect("index one is non-zero"));
7452        mgr.dispatch_pix_event(HoprSessionInPixEvent::SsaRecovered(ssa_id))
7453            .await?;
7454        assert!(
7455            wait_for_no_active_sessions(&mgr).await,
7456            "a recovered cycle must end the drain and tear the Session down"
7457        );
7458        assert_no_further_origination(&mut msg_rx, "a completed SURB drain").await;
7459        Ok(())
7460    }
7461
7462    /// A closed PIX Session with too few SURBs to finish is torn down as it always was.
7463    ///
7464    /// A hundred SURBs against [`fill_pix_params`]' 300-packet cycle: enough for the manager to offer
7465    /// the close to the supervisor, and not enough for the supervisor to take it. That is the
7466    /// arithmetic under test — draining on a buffer that runs out partway is strictly worse than not
7467    /// draining at all, since the SURBs are spent down to the reserve, the cycle recovers nothing,
7468    /// and every packet past the last useful one is return-routed on behalf of a Session that is gone.
7469    #[test_log::test(tokio::test)]
7470    async fn a_closed_pix_session_without_enough_surbs_originates_nothing() -> anyhow::Result<()> {
7471        let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7472            Capabilities::empty(),
7473            Some(MIN_SURB_BUFFER_NOTIFICATION_PERIOD),
7474            crate::supervision::PixFillConfig {
7475                min_surb_reserve: 1,
7476                ..Default::default()
7477            },
7478        )
7479        .await?;
7480
7481        mgr.sessions
7482            .get(&pseudonym)
7483            .context("the session slot must exist")?
7484            .surb_estimator
7485            .produced
7486            .fetch_add(100, std::sync::atomic::Ordering::Relaxed);
7487        anyhow::ensure!(
7488            keep_alives_during(&mut msg_rx, pseudonym, Duration::from_millis(1500)).await > 0,
7489            "the stream must be running before the close for its absence afterwards to mean anything"
7490        );
7491
7492        assert!(mgr.close_session(&pseudonym), "the session must exist to be closed");
7493        assert!(
7494            wait_for_no_active_sessions(&mgr).await,
7495            "a drain the supervisor refuses must still tear the Session down"
7496        );
7497        assert_no_further_origination(&mut msg_rx, "a close with too few SURBs to drain").await;
7498        Ok(())
7499    }
7500
7501    /// With `drain_after_close` off, every close path is what it was before draining existed.
7502    ///
7503    /// Including the part an operator would notice first: the slot is gone by the time
7504    /// `close_session` returns, rather than one channel hop later once the supervisor has answered.
7505    #[test_log::test(tokio::test)]
7506    async fn a_closed_pix_session_with_draining_disabled_originates_nothing() -> anyhow::Result<()> {
7507        let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7508            Capabilities::empty(),
7509            Some(Duration::from_secs(1)),
7510            crate::supervision::PixFillConfig {
7511                min_surb_reserve: 1,
7512                drain_after_close: false,
7513                ..Default::default()
7514            },
7515        )
7516        .await?;
7517
7518        mgr.sessions
7519            .get(&pseudonym)
7520            .context("the session slot must exist")?
7521            .surb_estimator
7522            .produced
7523            .fetch_add(100_000, std::sync::atomic::Ordering::Relaxed);
7524        anyhow::ensure!(
7525            keep_alives_during(&mut msg_rx, pseudonym, Duration::from_millis(1500)).await > 0,
7526            "the fixture's keep-alive stream never ran"
7527        );
7528
7529        assert!(mgr.close_session(&pseudonym), "the session must exist to be closed");
7530        assert!(
7531            mgr.active_sessions().is_empty(),
7532            "with draining disabled the slot must be gone by the time the close returns"
7533        );
7534        assert_no_further_origination(&mut msg_rx, "an explicit close with draining disabled").await;
7535        Ok(())
7536    }
7537
7538    /// Closing a draining Session a second time forces the teardown.
7539    ///
7540    /// The drain is the Exit's decision, taken on the operator's behalf, and this is the operator's
7541    /// way to overrule it: a second `SessionManager::close_session` — or a peer `SessionError`, which
7542    /// arrives on the same path — stops the origination immediately rather than waiting out a cycle.
7543    /// The end-of-stream a close of our own ingress produces is deliberately *not* on this path; it
7544    /// carries `WriteClosed`/`EmptyRead` and is absorbed.
7545    #[test_log::test(tokio::test)]
7546    async fn a_second_close_of_a_draining_session_tears_it_down() -> anyhow::Result<()> {
7547        let (mgr, mut msg_rx, pseudonym) = drainable_exit_pix_session(Duration::from_secs(1)).await?;
7548
7549        assert!(mgr.close_session(&pseudonym), "the first close starts the drain");
7550        assert!(
7551            mgr.active_sessions().contains(&pseudonym),
7552            "the drain must be running for the second close to have anything to overrule"
7553        );
7554
7555        assert!(mgr.close_session(&pseudonym), "the second close must find the session");
7556        assert!(
7557            mgr.active_sessions().is_empty(),
7558            "a second explicit close must remove the slot rather than let the drain run on"
7559        );
7560        assert_no_further_origination(&mut msg_rx, "a second explicit close of a draining session").await;
7561        Ok(())
7562    }
7563
7564    /// The same Session, with no SURBs to spend, must fall back to its SURB-level notification.
7565    ///
7566    /// A Session whose Entry has stopped supplying SURBs is the one case where filling harder is
7567    /// actively harmful: the packets cannot be routed, and an unroutable origination holds up every
7568    /// packet the node sends. What must survive is the notification, because it is the message that
7569    /// asks for the SURBs whose absence caused the backoff.
7570    #[test_log::test(tokio::test)]
7571    async fn fill_backs_off_to_the_notification_when_the_entry_supplies_no_surbs() -> anyhow::Result<()> {
7572        // At `MIN_SURB_BUFFER_NOTIFICATION_PERIOD`, so it survives the clamp `SessionManager::new`
7573        // applies. A shorter value would be raised to this one anyway, and the bound below would then
7574        // be computed from a period that is not the one in effect.
7575        let notify = MIN_SURB_BUFFER_NOTIFICATION_PERIOD;
7576        let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7577            Capabilities::empty(),
7578            Some(notify),
7579            crate::supervision::PixFillConfig {
7580                min_surb_reserve: 500,
7581                ..Default::default()
7582            },
7583        )
7584        .await?;
7585        assert_eq!(
7586            Some(notify),
7587            mgr.cfg.surb_balance_notify_period,
7588            "the fixture's notification period must be the one the manager actually runs"
7589        );
7590
7591        let _ramp = originated_during(&mut msg_rx, Duration::from_millis(1500)).await;
7592        let window = Duration::from_millis(1500);
7593        let observed = keep_alives_during(&mut msg_rx, pseudonym, window).await;
7594
7595        // A notification is promoted every `period - min(MAX_WAIT_CHUNK, period / 2)`, not every
7596        // period: `admit_at` opens the window a slack early so the classification does not rest on the
7597        // rate limiter's scheduling latency. The ceiling has to be computed from that shorter interval,
7598        // or a stream doing exactly what this test permits would be read as filling.
7599        let promote_every = notify - MAX_WAIT_CHUNK.min(notify / 2);
7600        let notify_only = (window.as_secs_f64() / promote_every.as_secs_f64()).ceil() as usize;
7601        assert!(
7602            observed > 0,
7603            "the SURB-level notification must survive the backoff — it is what asks for the SURBs"
7604        );
7605        assert!(
7606            observed <= notify_only + 1,
7607            "with no SURBs to spend the Exit originated {observed} keep-alive(s) over {window:?}, against the \
7608             {notify_only} its notification alone accounts for at one every {promote_every:?}"
7609        );
7610
7611        assert!(mgr.close_session(&pseudonym));
7612        Ok(())
7613    }
7614
7615    /// A `NoRateControl` PIX Session must still have a SURB level estimate, or its reserve is a lie.
7616    ///
7617    /// That branch runs no balancer, and until now it wired no estimator either: `produced` and
7618    /// `consumed` both stayed at zero for the life of the Session. A reserve measured against that
7619    /// counter would refuse every fill packet forever — which is the branch that can drain SURBs
7620    /// fastest, so it is the one where getting it wrong costs most.
7621    #[test_log::test(tokio::test)]
7622    async fn a_no_rate_control_pix_session_still_estimates_its_surb_level() -> anyhow::Result<()> {
7623        let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7624            Capability::NoRateControl.into(),
7625            Some(Duration::from_secs(1)),
7626            crate::supervision::PixFillConfig {
7627                min_surb_reserve: 1,
7628                ..Default::default()
7629            },
7630        )
7631        .await?;
7632
7633        let slot = mgr.sessions.get(&pseudonym).context("the session slot must exist")?;
7634        slot.surb_estimator
7635            .produced
7636            .fetch_add(100_000, std::sync::atomic::Ordering::Relaxed);
7637
7638        let _ = keep_alives_during(&mut msg_rx, pseudonym, Duration::from_millis(1500)).await;
7639
7640        assert!(
7641            slot.surb_estimator.consumed.load(std::sync::atomic::Ordering::Relaxed) > 0,
7642            "a NoRateControl Session that has sent keep-alives must have counted them as consumed SURBs"
7643        );
7644
7645        assert!(mgr.close_session(&pseudonym));
7646        Ok(())
7647    }
7648
7649    /// Verifies that established sessions exchange `KeepAlive` messages driven by the SURB balancer,
7650    /// that config updates propagate via keep-alives, and that SURB usage statistics are collected.
7651    ///
7652    /// ## Steps
7653    /// 1. Alice's manager is started with no `PixToolbox` and a `SurbBalancerConfig` with `target_surb_buffer_size:
7654    ///    10`. Bob's manager is configured with a 500ms `surb_balance_notify_period`.
7655    /// 2. Alice initiates a session with the balancer config and PIX quota set; the `StartSession` /
7656    ///    `SessionEstablished` handshake completes via mock transports.
7657    /// 3. Both managers report the same `target_surb_buffer_size` via `get_surb_balancer_config` (confirmed from both
7658    ///    Alice and Bob's perspective).
7659    /// 4. A 1500ms sleep allows the SURB balancer's periodic keep-alive timer to fire multiple times.
7660    /// 5. `update_surb_balancer_config` is called to raise the target to 50. After another 1500ms, Bob's manager
7661    ///    reflects the updated target via `get_surb_balancer_config`, confirming keep-alives communicated the change.
7662    /// 6. `get_surb_level_estimates` is called on both sides; both report positive sent/received/used counts,
7663    ///    confirming the balancer collected SURB statistics.
7664    /// 7. Alice closes the session; `ping_session` returns `NonExistingSession` after a short wait.
7665    #[test_log::test(tokio::test)]
7666    async fn session_manager_should_send_keep_alives_via_surb_balancer() -> anyhow::Result<()> {
7667        let alice_pseudonym = HoprPseudonym::random();
7668        let bob_peer: Address = (&ChainKeypair::random()).into();
7669
7670        let bob_cfg = SessionManagerConfig {
7671            surb_balance_notify_period: Some(Duration::from_millis(500)),
7672            ..Default::default()
7673        };
7674        let alice_mgr = SessionManager::new(Default::default());
7675        let bob_mgr = SessionManager::new(bob_cfg.clone());
7676
7677        let mut alice_transport = MockMsgSender::new();
7678        let mut bob_transport = MockMsgSender::new();
7679
7680        // Alice sends the StartSession message
7681        let mut open_sequence = mockall::Sequence::new();
7682        let bob_mgr_clone = bob_mgr.clone();
7683        alice_transport
7684            .expect_send_message()
7685            .once()
7686            .in_sequence(&mut open_sequence)
7687            .withf(move |peer, data| {
7688                msg_type(data, StartProtocolDiscriminants::StartSession)
7689                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
7690            })
7691            .returning(move |_, data| {
7692                let bob_mgr_clone = bob_mgr_clone.clone();
7693                Box::pin(async move {
7694                    bob_mgr_clone
7695                        .dispatch_message(
7696                            alice_pseudonym,
7697                            ApplicationDataIn {
7698                                data: data.data,
7699                                packet_info: Default::default(),
7700                            },
7701                        )
7702                        ?;
7703                    Ok(())
7704                })
7705            });
7706
7707        // Bob sends the SessionEstablished message
7708        let alice_mgr_clone = alice_mgr.clone();
7709        bob_transport
7710            .expect_send_message()
7711            .once()
7712            .in_sequence(&mut open_sequence)
7713            .withf(move |peer, data| {
7714                msg_type(data, StartProtocolDiscriminants::SessionEstablished)
7715                    && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if *p == alice_pseudonym)
7716            })
7717            .returning(move |_, data| {
7718                let alice_mgr_clone = alice_mgr_clone.clone();
7719                Box::pin(async move {
7720                    alice_mgr_clone.dispatch_message(
7721                        alice_pseudonym,
7722                        ApplicationDataIn {
7723                            data: data.data,
7724                            packet_info: Default::default(),
7725                        },
7726                    )?;
7727                    Ok(())
7728                })
7729            });
7730
7731        const INITIAL_BALANCER_TARGET: u64 = 10;
7732
7733        // Alice sends the KeepAlive messages reporting the initial balancer target
7734        let bob_mgr_clone = bob_mgr.clone();
7735        alice_transport
7736            .expect_send_message()
7737            .times(1..)
7738            //.in_sequence(&mut sequence)
7739            .withf(move |peer, data| {
7740                start_msg_match(data, |msg| matches!(msg, StartProtocol::KeepAlive(ka) if ka.flags.contains(KeepAliveFlag::BalancerTarget) && ka.additional_data == INITIAL_BALANCER_TARGET))
7741                //msg_type(data, StartProtocolDiscriminants::KeepAlive)
7742                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
7743            })
7744            .returning(move |_, data| {
7745                let bob_mgr_clone = bob_mgr_clone.clone();
7746                Box::pin(async move {
7747                    bob_mgr_clone
7748                        .dispatch_message(
7749                            alice_pseudonym,
7750                            ApplicationDataIn {
7751                                data: data.data,
7752                                packet_info: Default::default(),
7753                            },
7754                        )
7755                        ?;
7756                    Ok(())
7757                })
7758            });
7759
7760        const NEXT_BALANCER_TARGET: u64 = 50;
7761
7762        // Alice sends also the KeepAlive messages reporting the updated balancer target
7763        let bob_mgr_clone = bob_mgr.clone();
7764        alice_transport
7765            .expect_send_message()
7766            .times(1..)
7767            //.in_sequence(&mut sequence)
7768            .withf(move |peer, data| {
7769                start_msg_match(data, |msg| matches!(msg, StartProtocol::KeepAlive(ka) if ka.flags.contains(KeepAliveFlag::BalancerTarget) && ka.additional_data == NEXT_BALANCER_TARGET))
7770                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
7771            })
7772            .returning(move |_, data| {
7773                let bob_mgr_clone = bob_mgr_clone.clone();
7774                Box::pin(async move {
7775                    bob_mgr_clone
7776                        .dispatch_message(
7777                            alice_pseudonym,
7778                            ApplicationDataIn {
7779                                data: data.data,
7780                                packet_info: Default::default(),
7781                            },
7782                        )
7783                        ?;
7784                    Ok(())
7785                })
7786            });
7787
7788        // Bob sends at least 1 Keep Alive back reporting its SURB estimate
7789        let alice_mgr_clone = alice_mgr.clone();
7790        bob_transport
7791            .expect_send_message()
7792            .times(1..)
7793            //.in_sequence(&mut open_sequence)
7794            .withf(move |peer, data| {
7795                start_msg_match(data, |msg| matches!(msg, StartProtocol::KeepAlive(ka) if ka.flags.contains(KeepAliveFlag::BalancerState) && ka.additional_data > 0))
7796                    && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if *p == alice_pseudonym)
7797            })
7798            .returning(move |_, data| {
7799                let alice_mgr_clone = alice_mgr_clone.clone();
7800                Box::pin(async move {
7801                    alice_mgr_clone
7802                        .dispatch_message(
7803                            alice_pseudonym,
7804                            ApplicationDataIn {
7805                                data: data.data,
7806                                packet_info: Default::default(),
7807                            },
7808                        )
7809                        ?;
7810                    Ok(())
7811                })
7812            });
7813
7814        // Alice sends the terminating segment to close the Session
7815        let bob_mgr_clone = bob_mgr.clone();
7816        alice_transport
7817            .expect_send_message()
7818            .once()
7819            //.in_sequence(&mut sequence)
7820            .withf(move |peer, data| {
7821                hopr_protocol_session::types::SessionMessage::<{ ApplicationData::PAYLOAD_SIZE }>::try_from(
7822                    data.data.plain_text.as_ref(),
7823                )
7824                .ok()
7825                .and_then(|m| m.try_as_segment())
7826                .map(|s| s.is_terminating())
7827                .unwrap_or(false)
7828                    && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
7829            })
7830            .returning(move |_, data| {
7831                let bob_mgr_clone = bob_mgr_clone.clone();
7832                Box::pin(async move {
7833                    bob_mgr_clone
7834                        .dispatch_message(
7835                            alice_pseudonym,
7836                            ApplicationDataIn {
7837                                data: data.data,
7838                                packet_info: Default::default(),
7839                            },
7840                        )
7841                        ?;
7842                    Ok(())
7843                })
7844            });
7845
7846        let mut ahs = Vec::new();
7847
7848        // Start Alice
7849        let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
7850        let (alice_sender, alice_handle) = mock_packet_planning(alice_transport);
7851        ahs.extend(alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?);
7852        assert!(alice_mgr.is_started());
7853
7854        // Start Bob
7855        let (new_session_tx_bob, new_session_rx_bob) = futures::channel::mpsc::channel(1024);
7856        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
7857        ahs.extend(bob_mgr.start(bob_sender.clone(), new_session_tx_bob, None, None)?);
7858        assert!(bob_mgr.is_started());
7859
7860        let target = SealedHost::Plain("127.0.0.1:80".parse()?);
7861
7862        let balancer_cfg = SurbBalancerConfig {
7863            target_surb_buffer_size: INITIAL_BALANCER_TARGET,
7864            max_surbs_per_sec: 100,
7865            ..Default::default()
7866        };
7867
7868        pin_mut!(new_session_rx_bob);
7869        let (alice_session, bob_session) = timeout(
7870            Duration::from_secs(2),
7871            futures::future::join(
7872                alice_mgr.new_session(
7873                    bob_peer,
7874                    SessionTarget::TcpStream(target.clone()),
7875                    SessionClientConfig {
7876                        pseudonym: alice_pseudonym.into(),
7877                        capabilities: Capability::Segmentation.into(),
7878                        surb_management: Some(balancer_cfg),
7879                        ..Default::default()
7880                    },
7881                ),
7882                new_session_rx_bob.next(),
7883            ),
7884        )
7885        .await?;
7886
7887        let mut alice_session = alice_session?;
7888        let bob_session = bob_session.ok_or(anyhow!("bob must get an incoming session"))?;
7889
7890        assert!(matches!(bob_session.target, SessionTarget::TcpStream(host) if host == target));
7891
7892        assert_eq!(
7893            Some(balancer_cfg),
7894            alice_mgr.get_surb_balancer_config(alice_session.id())?
7895        );
7896
7897        // The organic SURB gate, on the real slot rather than a hand-built state. Two things it must
7898        // get right, both of which would deadlock a Session if inverted.
7899        {
7900            let alice_slot = alice_mgr
7901                .sessions
7902                .get(alice_session.id())
7903                .ok_or(anyhow!("alice must hold the session slot"))?;
7904
7905            // At cold start the gate must be open: readiness only waits for half the target, so a
7906            // gate that closed at or below that would stop the very production it is waiting on.
7907            assert_eq!(
7908                1,
7909                alice_slot.surb_mgmt.organic_surbs_per_packet(),
7910                "a freshly opened session must still produce organic SURBs"
7911            );
7912
7913            // And it must stay open on the counterparty's word, whatever the local estimate says.
7914            alice_slot
7915                .surb_mgmt
7916                .observe_counterparty_signals(PacketSignal::OutOfSurbs.into());
7917            alice_slot
7918                .surb_mgmt
7919                .buffer_level
7920                .store(u64::MAX, std::sync::atomic::Ordering::Relaxed);
7921            assert_eq!(
7922                1,
7923                alice_slot.surb_mgmt.organic_surbs_per_packet(),
7924                "a counterparty out of SURBs must override any local estimate"
7925            );
7926
7927            // Leave the slot as it was found; the balancer loop keeps running below.
7928            alice_slot
7929                .surb_mgmt
7930                .observe_counterparty_signals(PacketSignals::default());
7931            alice_slot
7932                .surb_mgmt
7933                .buffer_level
7934                .store(0, std::sync::atomic::Ordering::Relaxed);
7935        }
7936
7937        let remote_cfg = bob_mgr
7938            .get_surb_balancer_config(bob_session.session.id())?
7939            .ok_or(anyhow!("no remote config at bob"))?;
7940        assert_eq!(remote_cfg.target_surb_buffer_size, balancer_cfg.target_surb_buffer_size);
7941        assert_eq!(
7942            remote_cfg.max_surbs_per_sec,
7943            remote_cfg.target_surb_buffer_size
7944                / bob_cfg
7945                    .minimum_surb_buffer_duration
7946                    .max(MIN_SURB_BUFFER_DURATION)
7947                    .as_secs()
7948        );
7949
7950        // Let the Surb balancer send enough KeepAlive messages
7951        tokio::time::sleep(Duration::from_millis(1500)).await;
7952
7953        let new_balancer_cfg = SurbBalancerConfig {
7954            target_surb_buffer_size: NEXT_BALANCER_TARGET,
7955            max_surbs_per_sec: 100,
7956            ..Default::default()
7957        };
7958
7959        // Update to a higher target
7960        alice_mgr.update_surb_balancer_config(alice_session.id(), new_balancer_cfg)?;
7961
7962        // Let the Surb balancer send enough KeepAlive messages
7963        tokio::time::sleep(Duration::from_millis(1500)).await;
7964
7965        // Bob should know about the updated target
7966        let remote_cfg = bob_mgr
7967            .get_surb_balancer_config(bob_session.session.id())?
7968            .ok_or(anyhow!("no remote config at bob"))?;
7969        assert_eq!(
7970            remote_cfg.target_surb_buffer_size,
7971            new_balancer_cfg.target_surb_buffer_size
7972        );
7973        assert_eq!(
7974            remote_cfg.max_surbs_per_sec,
7975            new_balancer_cfg.target_surb_buffer_size / bob_cfg.minimum_surb_buffer_duration.as_secs()
7976        );
7977
7978        let (alice_surb_sent, alice_surb_used) = alice_mgr.get_surb_level_estimates(alice_session.id())?;
7979        let (bob_surb_recv, bob_surb_used) = bob_mgr.get_surb_level_estimates(bob_session.session.id())?;
7980
7981        alice_session.close().await?;
7982
7983        assert!(alice_surb_sent > 0, "alice must've sent surbs");
7984        assert!(bob_surb_recv > 0, "bob must've received surbs");
7985        assert!(
7986            bob_surb_recv <= alice_surb_sent,
7987            "bob cannot receive more surbs than alice sent"
7988        );
7989
7990        assert!(alice_surb_used > 0, "alice must see bob used surbs");
7991        assert!(bob_surb_used > 0, "bob must've used surbs");
7992        assert!(
7993            alice_surb_used <= bob_surb_used,
7994            "alice cannot see bob used more surbs than bob actually used"
7995        );
7996
7997        tokio::time::sleep(Duration::from_millis(300)).await;
7998        assert!(matches!(
7999            alice_mgr.ping_session(alice_session.id()).await,
8000            Err(TransportSessionError::Manager(SessionManagerError::NonExistingSession))
8001        ));
8002
8003        futures::stream::iter(ahs)
8004            .for_each(|ah| async move { ah.abort() })
8005            .await;
8006
8007        // Cleanup: close senders and await handles
8008        alice_sender.close_channel();
8009        bob_sender.close_channel();
8010        alice_handle.await??;
8011        bob_handle.await??;
8012
8013        Ok(())
8014    }
8015
8016    /// Verifies that a second incoming session initiation for the same pseudonym is handled gracefully
8017    /// (returns `Ok`) without creating a duplicate session slot.
8018    ///
8019    /// ## Steps
8020    /// 1. A `SessionManager` is started with a mock transport that accepts two outbound messages.
8021    /// 2. `handle_incoming_session_initiation` is called with pseudonym `X` — succeeds; exactly one active session is
8022    ///    confirmed.
8023    /// 3. `handle_incoming_session_initiation` is called again with the same pseudonym `X`. The manager detects the
8024    ///    conflict and handles it internally by sending a `SessionError` to the peer.
8025    /// 4. The call still returns `Ok` (error is handled internally); `num_active_sessions` remains 1 with only the
8026    ///    original pseudonym present.
8027    #[test_log::test(tokio::test)]
8028    async fn session_manager_should_reject_duplicate_session_for_same_pseudonym() -> anyhow::Result<()> {
8029        use hopr_utils::network_types::prelude::SealedHost;
8030
8031        let bob_mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8032            SessionManager::new(Default::default());
8033
8034        // Start the manager (required for handling incoming sessions)
8035        let mut transport = MockMsgSender::new();
8036        transport
8037            .expect_send_message()
8038            .times(2)
8039            .returning(|_, _| futures::future::ok(()).boxed());
8040
8041        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8042        // Spawn a task to receive new session notifications
8043        let _notifications = tokio::spawn(async move {
8044            pin_mut!(new_session_rx);
8045            while let Some(_session) = new_session_rx.next().await {
8046                // Just drain the channel
8047            }
8048        });
8049        let (sender, _handle) = mock_packet_planning(transport);
8050        bob_mgr.start(sender.clone(), new_session_tx, None, None)?;
8051        assert!(bob_mgr.is_started());
8052
8053        let pseudonym = HoprPseudonym::random();
8054
8055        // First session initiation - should succeed
8056        let result = bob_mgr
8057            .handle_incoming_session_initiation(
8058                pseudonym,
8059                StartInitiation {
8060                    challenge: MIN_CHALLENGE,
8061                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
8062                    capabilities: HoprSessionCapabilities::empty(),
8063                    additional_data: 0,
8064                },
8065            )
8066            .await;
8067
8068        assert!(result.is_ok(), "first session initiation should succeed");
8069
8070        // Verify one session exists
8071        let active = bob_mgr.active_sessions();
8072        assert_eq!(active.len(), 1, "should have exactly one active session");
8073        assert_eq!(bob_mgr.num_active_sessions(), 1);
8074
8075        // Second session initiation with same pseudonym - should be handled gracefully
8076        // (returns Ok but sends SessionError to the requester)
8077        let result = bob_mgr
8078            .handle_incoming_session_initiation(
8079                pseudonym,
8080                StartInitiation {
8081                    challenge: MIN_CHALLENGE,
8082                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
8083                    capabilities: HoprSessionCapabilities::empty(),
8084                    additional_data: 0,
8085                },
8086            )
8087            .await;
8088
8089        // The second initiation returns Ok but handles the duplicate by sending SessionError
8090        assert!(
8091            result.is_ok(),
8092            "second session initiation should return Ok (error is handled internally)"
8093        );
8094
8095        // Verify still only one session exists
8096        let active = bob_mgr.active_sessions();
8097        assert_eq!(active.len(), 1, "should still have exactly one active session");
8098        assert_eq!(bob_mgr.num_active_sessions(), 1);
8099
8100        // Cleanup: close sender and await handle
8101        sender.close_channel();
8102        _handle.await??;
8103
8104        Ok(())
8105    }
8106
8107    /// Verifies that pinging a session that does not exist returns `NonExistingSession`.
8108    ///
8109    /// ## Steps
8110    /// 1. A `SessionManager` is started with a mock transport.
8111    /// 2. `ping_session` is called with a completely random (non-existent) session ID.
8112    /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::NonExistingSession)`.
8113    /// 4. `num_active_sessions` is 0, confirming no sessions were created.
8114    #[test_log::test(tokio::test)]
8115    async fn session_manager_should_return_error_when_pinging_non_existent_session() -> anyhow::Result<()> {
8116        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8117            SessionManager::new(Default::default());
8118
8119        let transport = MockMsgSender::new();
8120        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8121        let _notifications = tokio::spawn(async move {
8122            pin_mut!(new_session_rx);
8123            while let Some(_session) = new_session_rx.next().await {}
8124        });
8125        let (sender, _handle) = mock_packet_planning(transport);
8126        mgr.start(sender.clone(), new_session_tx, None, None)?;
8127        assert!(mgr.is_started());
8128
8129        let fake_session_id = HoprPseudonym::random();
8130        assert_eq!(mgr.num_active_sessions(), 0);
8131        let result = mgr.ping_session(&fake_session_id).await;
8132
8133        assert!(result.is_err());
8134        assert!(matches!(
8135            result.unwrap_err(),
8136            TransportSessionError::Manager(SessionManagerError::NonExistingSession)
8137        ));
8138
8139        // Cleanup: close sender and await handle
8140        sender.close_channel();
8141        _handle.await??;
8142
8143        Ok(())
8144    }
8145
8146    /// Verifies that closing a session that does not exist returns `false` (no-op).
8147    ///
8148    /// ## Steps
8149    /// 1. A `SessionManager` is started with a mock transport.
8150    /// 2. `close_session` is called with a random (non-existent) session ID.
8151    /// 3. The call returns `false`, indicating no session was closed.
8152    #[test_log::test(tokio::test)]
8153    async fn session_manager_should_return_false_when_closing_non_existent_session() -> anyhow::Result<()> {
8154        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8155            SessionManager::new(Default::default());
8156
8157        let transport = MockMsgSender::new();
8158        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8159        let _notifications = tokio::spawn(async move {
8160            pin_mut!(new_session_rx);
8161            while let Some(_session) = new_session_rx.next().await {}
8162        });
8163        let (sender, _handle) = mock_packet_planning(transport);
8164        mgr.start(sender.clone(), new_session_tx, None, None)?;
8165        assert!(mgr.is_started());
8166
8167        let fake_session_id = HoprPseudonym::random();
8168        assert_eq!(mgr.num_active_sessions(), 0);
8169        let result = mgr.close_session(&fake_session_id);
8170
8171        assert!(!result, "closing non-existent session should return false");
8172
8173        Ok(())
8174    }
8175
8176    /// Verifies that updating the SURB balancer config for a non-existent session returns an error.
8177    ///
8178    /// ## Steps
8179    /// 1. A `SessionManager` is started with a mock transport.
8180    /// 2. `update_surb_balancer_config` is called with a random session ID.
8181    /// 3. The call returns an error (no `Ok` variant is expected).
8182    #[test_log::test(tokio::test)]
8183    async fn session_manager_should_return_error_when_updating_surb_config_for_non_existent_session()
8184    -> anyhow::Result<()> {
8185        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8186            SessionManager::new(Default::default());
8187
8188        let transport = MockMsgSender::new();
8189        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8190        let _notifications = tokio::spawn(async move {
8191            pin_mut!(new_session_rx);
8192            while let Some(_session) = new_session_rx.next().await {}
8193        });
8194        let (sender, _handle) = mock_packet_planning(transport);
8195        mgr.start(sender.clone(), new_session_tx, None, None)?;
8196        assert!(mgr.is_started());
8197
8198        let fake_session_id = HoprPseudonym::random();
8199        let result = mgr.update_surb_balancer_config(&fake_session_id, SurbBalancerConfig::default());
8200
8201        assert!(result.is_err());
8202
8203        // Cleanup: close sender and await handle
8204        sender.close_channel();
8205        _handle.await??;
8206
8207        Ok(())
8208    }
8209
8210    /// Verifies that retrieving the SURB balancer config for a non-existent session returns an error.
8211    ///
8212    /// ## Steps
8213    /// 1. A `SessionManager` is started with a mock transport.
8214    /// 2. `get_surb_balancer_config` is called with a random session ID.
8215    /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::NonExistingSession)`.
8216    #[test_log::test(tokio::test)]
8217    async fn session_manager_should_return_error_when_getting_surb_config_for_non_existent_session()
8218    -> anyhow::Result<()> {
8219        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8220            SessionManager::new(Default::default());
8221
8222        let transport = MockMsgSender::new();
8223        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8224        let _notifications = tokio::spawn(async move {
8225            pin_mut!(new_session_rx);
8226            while let Some(_session) = new_session_rx.next().await {}
8227        });
8228        let (sender, _handle) = mock_packet_planning(transport);
8229        mgr.start(sender.clone(), new_session_tx, None, None)?;
8230        assert!(mgr.is_started());
8231
8232        let fake_session_id = HoprPseudonym::random();
8233        let result = mgr.get_surb_balancer_config(&fake_session_id);
8234
8235        assert!(result.is_err());
8236        assert!(matches!(
8237            result.unwrap_err(),
8238            TransportSessionError::Manager(SessionManagerError::NonExistingSession)
8239        ));
8240
8241        // Cleanup: close sender and await handle
8242        sender.close_channel();
8243        _handle.await??;
8244
8245        Ok(())
8246    }
8247
8248    /// Verifies that retrieving SURB level estimates for a non-existent session returns an error.
8249    ///
8250    /// ## Steps
8251    /// 1. A `SessionManager` is started with a mock transport.
8252    /// 2. `get_surb_level_estimates` is called with a random session ID.
8253    /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::NonExistingSession)`.
8254    #[test_log::test(tokio::test)]
8255    async fn session_manager_should_return_error_when_getting_surb_estimates_for_non_existent_session()
8256    -> anyhow::Result<()> {
8257        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8258            SessionManager::new(Default::default());
8259
8260        let transport = MockMsgSender::new();
8261        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8262        let _notifications = tokio::spawn(async move {
8263            pin_mut!(new_session_rx);
8264            while let Some(_session) = new_session_rx.next().await {}
8265        });
8266        let (sender, _handle) = mock_packet_planning(transport);
8267        mgr.start(sender.clone(), new_session_tx, None, None)?;
8268        assert!(mgr.is_started());
8269
8270        let fake_session_id = HoprPseudonym::random();
8271        let result = mgr.get_surb_level_estimates(&fake_session_id);
8272
8273        assert!(result.is_err());
8274        assert!(matches!(
8275            result.unwrap_err(),
8276            TransportSessionError::Manager(SessionManagerError::NonExistingSession)
8277        ));
8278
8279        // Cleanup: close sender and await handle
8280        sender.close_channel();
8281        _handle.await??;
8282
8283        Ok(())
8284    }
8285
8286    /// Verifies the `HoprStartProtocol::SessionError` match arm (line 689) in the
8287    /// `session_start_protocol_processor` task by calling `handle_session_error` directly.
8288    ///
8289    /// When a `SessionError` message is delivered while a `new_session` call is awaiting,
8290    /// `handle_session_error` retrieves the pending challenge from `session_initiations`,
8291    /// sends the error down the channel, and `new_session` propagates it as `Rejected`.
8292    #[test_log::test(tokio::test)]
8293    async fn handle_session_error_propagates_peer_rejection_to_pending_new_session() -> anyhow::Result<()> {
8294        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8295            SessionManager::new(Default::default());
8296
8297        let mut transport = MockMsgSender::new();
8298        // new_session sends StartSession (succeeds), then waits for SessionEstablished.
8299        // We inject the error before it arrives.
8300        transport
8301            .expect_send_message()
8302            .returning(|_, _| futures::future::ok(()).boxed());
8303
8304        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8305        let _notifications = tokio::spawn(async move {
8306            pin_mut!(new_session_rx);
8307            while let Some(_session) = new_session_rx.next().await {}
8308        });
8309        let (sender, _handle) = mock_packet_planning(transport);
8310        mgr.start(sender.clone(), new_session_tx, None, None)?;
8311        assert!(mgr.is_started());
8312
8313        // Spawn new_session so it is blocked waiting for the session establishment response.
8314        let mgr_clone = mgr.clone();
8315        let peer_address: Address = (&ChainKeypair::random()).into();
8316        let handle = tokio::spawn(async move {
8317            mgr_clone
8318                .new_session(
8319                    peer_address,
8320                    SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
8321                    SessionClientConfig {
8322                        surb_management: None,
8323                        ..Default::default()
8324                    },
8325                )
8326                .await
8327        });
8328
8329        // Give new_session time to insert the challenge into session_initiations.
8330        let challenge = tokio::time::timeout(Duration::from_secs(1), async {
8331            loop {
8332                if let Some((ch, _)) = mgr.session_initiations.iter().next() {
8333                    break *ch;
8334                }
8335                tokio::time::sleep(Duration::from_millis(10)).await;
8336            }
8337        })
8338        .await
8339        .context("new_session did not insert a challenge into session_initiations")?;
8340
8341        // Inject the SessionError with the matching challenge before SessionEstablished arrives.
8342        let error_type = StartErrorType {
8343            identifier: ErrorIdentifier::Challenge(challenge),
8344            reason: StartErrorReason::NoSlotsAvailable,
8345        };
8346        mgr.handle_session_error(error_type).await?;
8347
8348        // new_session must propagate the error as Rejected.
8349        let result = handle.await?;
8350        match result {
8351            Ok(_session) => panic!("expected rejection error, got session"),
8352            Err(e) => {
8353                assert!(matches!(
8354                    e,
8355                    TransportSessionError::Rejected(StartErrorReason::NoSlotsAvailable)
8356                ));
8357            }
8358        }
8359
8360        sender.close_channel();
8361        _handle.await??;
8362        Ok(())
8363    }
8364
8365    /// Verifies that an incoming session initiation is rejected (handled internally) when the
8366    /// manager already has `maximum_sessions` active sessions.
8367    ///
8368    /// ## Steps
8369    /// 1. A `SessionManager` is configured with `maximum_sessions: 1`.
8370    /// 2. `handle_incoming_session_initiation` is called with pseudonym `X1` — succeeds; one active session confirmed.
8371    /// 3. `handle_incoming_session_initiation` is called with pseudonym `X2` — the manager detects it is at capacity
8372    ///    and handles the conflict internally (sends `SessionError` to peer).
8373    /// 4. The call returns `Ok` (handled internally); `num_active_sessions` remains 1, with only `X1` present — `X2`
8374    ///    was rejected without creating a slot.
8375    #[test_log::test(tokio::test)]
8376    async fn session_manager_should_reject_new_session_when_max_sessions_reached() -> anyhow::Result<()> {
8377        use hopr_utils::network_types::prelude::SealedHost;
8378
8379        // Create manager with max 1 session
8380        let cfg = SessionManagerConfig {
8381            maximum_sessions: 1,
8382            ..Default::default()
8383        };
8384        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8385            SessionManager::new(cfg);
8386
8387        let mut transport = MockMsgSender::new();
8388        transport
8389            .expect_send_message()
8390            .times(2)
8391            .returning(|_, _| futures::future::ok(()).boxed());
8392
8393        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8394        let _notifications = tokio::spawn(async move {
8395            pin_mut!(new_session_rx);
8396            while let Some(_session) = new_session_rx.next().await {}
8397        });
8398        let (sender, _handle) = mock_packet_planning(transport);
8399        mgr.start(sender.clone(), new_session_tx, None, None)?;
8400        assert!(mgr.is_started());
8401
8402        // First session - should succeed
8403        let pseudonym1 = HoprPseudonym::random();
8404        mgr.handle_incoming_session_initiation(
8405            pseudonym1,
8406            StartInitiation {
8407                challenge: MIN_CHALLENGE,
8408                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
8409                capabilities: HoprSessionCapabilities::empty(),
8410                additional_data: 0,
8411            },
8412        )
8413        .await?;
8414
8415        // Verify one session exists
8416        assert_eq!(mgr.active_sessions().len(), 1);
8417        assert_eq!(mgr.num_active_sessions(), 1);
8418
8419        // Second session - should fail with TooManySessions
8420        let pseudonym2 = HoprPseudonym::random();
8421        let _result = mgr
8422            .handle_incoming_session_initiation(
8423                pseudonym2,
8424                StartInitiation {
8425                    challenge: MIN_CHALLENGE,
8426                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
8427                    capabilities: HoprSessionCapabilities::empty(),
8428                    additional_data: 0,
8429                },
8430            )
8431            .await;
8432
8433        // The error is handled internally (sends SessionError), so result is Ok
8434        // But we can verify no new session was added
8435        assert_eq!(mgr.active_sessions().len(), 1);
8436        assert_eq!(mgr.num_active_sessions(), 1);
8437
8438        // Cleanup: close sender and await handle
8439        sender.close_channel();
8440        _handle.await??;
8441
8442        Ok(())
8443    }
8444
8445    /// The node refuses a PIX Session it has no reconstructor memory left for, and does so before
8446    /// allocating anything.
8447    ///
8448    /// This is the bound `maximum_sessions` cannot express: a session slot is one slot whatever the
8449    /// peer offered, while the reconstructor state behind it is set by the peer's dimensions. Here
8450    /// `maximum_sessions` is wide open and the budget is exactly one Session's worth, so nothing but
8451    /// the budget can be doing the refusing.
8452    ///
8453    /// Refused, not queued: nothing later in establishment can give the memory back, and the peer is
8454    /// free to retry against another Exit.
8455    #[test_log::test(tokio::test)]
8456    async fn a_pix_session_over_the_live_cycle_budget_is_refused() -> anyhow::Result<()> {
8457        use hopr_utils::network_types::prelude::SealedHost;
8458
8459        // Four shares has only one legal dimension split: 2 polynomials at threshold 2. Using no
8460        // surplus makes this offer the worst-case reservation at its exact quota, so the configured
8461        // budget can honestly admit the whole range while still fitting exactly one such Session.
8462        let params = PixParams::try_new(2, 2, 0, LOCAL_PIX_SUITE)?;
8463        let quota = pix_params_to_quota(&params);
8464        let one_session = cycle_budget_for(&params, DEFAULT_SSAS_PER_SSA_REQUEST);
8465        assert_eq!(one_session, max_cycle_budget_for_quota(quota, 1));
8466
8467        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8468            SessionManager::new(SessionManagerConfig {
8469                maximum_sessions: 100,
8470                pix_config: IncomingSessionPixConfig {
8471                    quota_range: quota..=quota,
8472                    // Room for exactly one Session at these dimensions.
8473                    max_live_cycle_bytes: one_session,
8474                    ..Default::default()
8475                },
8476                ..Default::default()
8477            });
8478
8479        let ssa_gen_config = SsaGeneratorConfig {
8480            polynomials_per_ssa: 2,
8481            threshold: 2,
8482            surplus_shares: 0,
8483        };
8484        let (pix_toolbox, _pix_events_rx) = PixToolbox::new(
8485            SsaShareGenerator::new(ssa_gen_config).into(),
8486            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
8487        );
8488
8489        let mut transport = MockMsgSender::new();
8490        transport
8491            .expect_send_message()
8492            .returning(|_, _| futures::future::ok(()).boxed());
8493        let (sender, _handle) = mock_packet_planning(transport);
8494        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8495        let _notifications = tokio::spawn(async move {
8496            pin_mut!(new_session_rx);
8497            while let Some(_session) = new_session_rx.next().await {}
8498        });
8499        mgr.start(sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
8500
8501        let offer = |pseudonym| {
8502            (
8503                pseudonym,
8504                StartInitiation {
8505                    challenge: MIN_CHALLENGE,
8506                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse().unwrap())),
8507                    capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
8508                    additional_data: into_initiation_additional_data(params.into_additional_data(0)),
8509                },
8510            )
8511        };
8512
8513        let (p1, req1) = offer(HoprPseudonym::random());
8514        mgr.handle_incoming_session_initiation(p1, req1).await?;
8515        assert_eq!(1, mgr.num_active_sessions(), "the first PIX session must be admitted");
8516        assert_eq!(
8517            one_session,
8518            mgr.live_cycle_bytes.load(Ordering::Relaxed),
8519            "and must have charged exactly one session's worth"
8520        );
8521
8522        // The budget is spent, so this one is refused — handled internally as a `SessionError`,
8523        // hence `Ok` with no new slot.
8524        let (p2, req2) = offer(HoprPseudonym::random());
8525        mgr.handle_incoming_session_initiation(p2, req2).await?;
8526        assert_eq!(
8527            1,
8528            mgr.num_active_sessions(),
8529            "a session over the live-cycle budget must not be admitted"
8530        );
8531        assert_eq!(
8532            one_session,
8533            mgr.live_cycle_bytes.load(Ordering::Relaxed),
8534            "and a refused session must not leave a reservation behind"
8535        );
8536
8537        // Closing the admitted session returns its share, and the next request fits again.
8538        assert!(mgr.close_session(&p1));
8539        assert_eq!(
8540            0,
8541            mgr.live_cycle_bytes.load(Ordering::Relaxed),
8542            "closing a session must return its reservation"
8543        );
8544
8545        let (p3, req3) = offer(HoprPseudonym::random());
8546        mgr.handle_incoming_session_initiation(p3, req3).await?;
8547        assert_eq!(
8548            1,
8549            mgr.num_active_sessions(),
8550            "the freed budget must admit the next session"
8551        );
8552
8553        sender.close_channel();
8554        _handle.await??;
8555        Ok(())
8556    }
8557
8558    /// A Session is charged for the dimensions its *peer* offered, not for this node's defaults.
8559    ///
8560    /// The Exit accepts a range of dimensions, and the reconstructor state behind them differs by
8561    /// more than an order of magnitude across it. Charging a flat figure would either refuse small
8562    /// Sessions that fit easily or admit large ones that do not.
8563    #[test]
8564    fn the_live_cycle_reservation_scales_with_the_offered_dimensions() -> anyhow::Result<()> {
8565        let small = PixParams::try_new(1024, 64, 16, LOCAL_PIX_SUITE)?;
8566        let large = PixParams::try_new(8192, 64, 16, LOCAL_PIX_SUITE)?;
8567
8568        assert_eq!(
8569            8 * cycle_budget_for(&small, 1),
8570            cycle_budget_for(&large, 1),
8571            "eight times the polynomials must cost eight times the budget"
8572        );
8573        assert_eq!(
8574            3 * cycle_budget_for(&large, 1),
8575            cycle_budget_for(&large, 3),
8576            "and a batch of three must cost three times a batch of one"
8577        );
8578
8579        // The pipelining factor is in there once, and only once: a batch may have one successor
8580        // outstanding, not one per member.
8581        assert_eq!(
8582            MAX_OVERLAPPING_BATCHES * hopr_protocol_pix::peak_cycle_bytes::<HoprPixSpec>(&large),
8583            cycle_budget_for(&large, 1)
8584        );
8585
8586        // The clamp matches the one `SessionManager::new` applies, so a config that never went
8587        // through it cannot understate its own reservation.
8588        assert_eq!(cycle_budget_for(&large, 1), cycle_budget_for(&large, 0));
8589        assert_eq!(
8590            cycle_budget_for(&large, MAX_SSA_BATCH_SIZE),
8591            cycle_budget_for(&large, usize::MAX)
8592        );
8593
8594        Ok(())
8595    }
8596
8597    /// Runs an admission handler against `mgr`'s request channel for the duration of one test.
8598    ///
8599    /// Mirrors what `hopr-lib` spawns beside the session server, minus the concurrency: these tests
8600    /// drive one initiation at a time.
8601    fn spawn_admission_handler(
8602        handler: impl Fn(SessionAdmissionRequest) -> Result<SessionAdmissionDecision, String> + Send + 'static,
8603    ) -> (SessionAdmissionSink, tokio::task::JoinHandle<()>) {
8604        let (tx, mut rx) =
8605            futures::channel::mpsc::channel::<(SessionAdmissionRequest, crate::types::SessionAdmissionReply)>(4);
8606        let handle = tokio::task::spawn(async move {
8607            while let Some((request, reply)) = rx.next().await {
8608                let _ = reply.send(handler(request));
8609            }
8610        });
8611        (tx, handle)
8612    }
8613
8614    /// What [`started_manager_admitting_small_params`] hands back: the manager, its message sender
8615    /// and the task draining it, the first `SessionError` it replies with, and the incoming-session
8616    /// receiver — which the test must hold, since an admitted Session is published on it and a
8617    /// dropped receiver would turn every success into a send failure.
8618    type AdmissionFixture = (
8619        SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>,
8620        UnboundedSender<(DestinationRouting, ApplicationDataOut)>,
8621        tokio::task::JoinHandle<anyhow::Result<()>>,
8622        oneshot::Receiver<StartErrorType<SessionId>>,
8623        futures::channel::mpsc::Receiver<IncomingSession>,
8624    );
8625
8626    /// A started manager that accepts an Entry offering [`small_pix_params`], with `admission`
8627    /// installed as its session server, so that any refusal is attributable to the decision rather
8628    /// than to the dimensions or to a missing toolbox.
8629    fn started_manager_admitting_small_params(
8630        quota_range: std::ops::RangeInclusive<SsaQuota>,
8631        enforce_pix: bool,
8632        admission: Option<SessionAdmissionSink>,
8633    ) -> anyhow::Result<AdmissionFixture> {
8634        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
8635
8636        let mgr = SessionManager::new(SessionManagerConfig {
8637            pix_config: IncomingSessionPixConfig {
8638                enforce_pix,
8639                quota_range,
8640                ..Default::default()
8641            },
8642            session_admission_timeout: Duration::from_millis(200),
8643            ..Default::default()
8644        });
8645
8646        let pix_toolbox = pix_toolbox_with_pool(
8647            SsaShareGenerator::new(SsaGeneratorConfig {
8648                polynomials_per_ssa: 2,
8649                threshold: 2,
8650                surplus_shares: TEST_SURPLUS_SHARES,
8651            })
8652            .into(),
8653            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
8654        );
8655
8656        let mut transport = MockMsgSender::new();
8657        let (err_tx, err_rx) = oneshot::channel();
8658        let err_tx = Arc::new(std::sync::Mutex::new(Some(err_tx)));
8659        transport.expect_send_message().returning(move |_, data| {
8660            let err_tx = err_tx.clone();
8661            Box::pin(async move {
8662                if let Ok(HoprStartProtocol::SessionError(err)) =
8663                    HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
8664                    && let Some(tx) = err_tx.lock().unwrap().take()
8665                {
8666                    let _ = tx.send(err);
8667                }
8668                Ok(())
8669            })
8670        });
8671
8672        let (sender, handle) = mock_packet_planning(transport);
8673        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8674        mgr.start(sender.clone(), new_session_tx, Some(pix_toolbox), admission)?;
8675
8676        Ok((mgr, sender, handle, err_rx, new_session_rx))
8677    }
8678
8679    /// An Entry offering [`small_pix_params`], i.e. an offer the fixture's node accepts.
8680    fn pix_offer() -> StartInitiation<SessionTarget, HoprSessionCapabilities> {
8681        StartInitiation {
8682            challenge: MIN_CHALLENGE,
8683            target: SessionTarget::TcpStream(SealedHost::Plain(
8684                "127.0.0.1:80".parse().expect("test target must parse"),
8685            )),
8686            capabilities: HoprSessionCapabilities(Capability::Segmentation | Capability::UsePIX),
8687            additional_data: small_pix_additional_data(),
8688        }
8689    }
8690
8691    /// The same target from an Entry that offered no incentivization at all.
8692    fn no_pix_offer() -> StartInitiation<SessionTarget, HoprSessionCapabilities> {
8693        StartInitiation {
8694            capabilities: HoprSessionCapabilities(Capability::Segmentation.into()),
8695            additional_data: 0,
8696            ..pix_offer()
8697        }
8698    }
8699
8700    /// The quota an Entry offering [`small_pix_params`] asks for.
8701    fn small_params_quota() -> SsaQuota {
8702        pix_params_to_quota(&small_pix_params())
8703    }
8704
8705    /// A node's configured window is an envelope, not a default: a decision may take less of it, but
8706    /// asking for more of it than was configured — and validated, and preallocated against — leaves
8707    /// the envelope's own bounds in place.
8708    #[test]
8709    fn an_admission_decision_narrows_the_configured_quota_window_and_never_widens_it() {
8710        let cfg = IncomingSessionPixConfig {
8711            quota_range: 100..=200,
8712            ..Default::default()
8713        };
8714
8715        let narrowed = cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(120..=180));
8716        assert_eq!(narrowed.quota_range, 120..=180);
8717
8718        let widened = cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(1..=1000));
8719        assert_eq!(widened.quota_range, 100..=200, "neither bound may leave the envelope");
8720
8721        let one_end = cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(150..=u64::MAX));
8722        assert_eq!(one_end.quota_range, 150..=200, "an open end leaves that bound alone");
8723
8724        let disjoint = cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(500..=600));
8725        assert!(
8726            disjoint.quota_range.is_empty(),
8727            "a rule that does not overlap the envelope admits nothing, rather than silently picking one"
8728        );
8729    }
8730
8731    /// Unlike the quota window, `enforce_pix` is coupled to nothing validated at startup, so a
8732    /// decision simply replaces it — in either direction.
8733    #[test]
8734    fn an_admission_decision_overrides_enforce_pix_in_both_directions() {
8735        let strict = IncomingSessionPixConfig {
8736            enforce_pix: true,
8737            ..Default::default()
8738        };
8739        let lax = IncomingSessionPixConfig {
8740            enforce_pix: false,
8741            ..Default::default()
8742        };
8743
8744        assert!(strict.effective_for(&SessionAdmissionDecision::default()).enforce_pix);
8745        assert!(
8746            !strict
8747                .effective_for(&SessionAdmissionDecision::default().with_enforce_pix(false))
8748                .enforce_pix
8749        );
8750        assert!(!lax.effective_for(&SessionAdmissionDecision::default()).enforce_pix);
8751        assert!(
8752            lax.effective_for(&SessionAdmissionDecision::default().with_enforce_pix(true))
8753                .enforce_pix
8754        );
8755    }
8756
8757    /// Runs an initiation through the same two steps admission does — read the offer off the wire,
8758    /// then judge it against the node's own terms. `None` is a refusal from either.
8759    fn decode_and_check(
8760        mgr: &SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>,
8761        req: &StartInitiation<SessionTarget, HoprSessionCapabilities>,
8762    ) -> Option<(PixParams, usize)> {
8763        let policy = mgr.cfg.pix_config.effective_for(&SessionAdmissionDecision::default());
8764        decode_pix_offer(req)
8765            .ok()
8766            .and_then(|offered| mgr.check_pix_params(req.challenge, offered, &policy))
8767    }
8768
8769    /// The offer must reach the session server decoded, and the capability bits verbatim.
8770    ///
8771    /// Without it the server can only price a target blind: it names a window with no idea whether
8772    /// the peer is anywhere near it, and cannot hold a *dimension* to a floor, since the quota is
8773    /// their product and the same product admits many splits.
8774    #[test_log::test(tokio::test)]
8775    async fn the_admission_request_carries_the_decoded_offer_and_capability_bits() -> anyhow::Result<()> {
8776        let (seen_tx, seen_rx) = oneshot::channel();
8777        let seen_tx = Arc::new(std::sync::Mutex::new(Some(seen_tx)));
8778        let (admission, handler) = spawn_admission_handler(move |request| {
8779            if let Some(tx) = seen_tx.lock().unwrap().take() {
8780                let _ = tx.send((request.capabilities, request.offered));
8781            }
8782            Ok(SessionAdmissionDecision::default())
8783        });
8784        let (mgr, sender, transport, _err_rx, _incoming) =
8785            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
8786
8787        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
8788            .await?;
8789
8790        let (capabilities, offered) = seen_rx.await.context("admission was never asked")?;
8791        let offered = offered.context("an offer was made, so one must have been reported")?;
8792
8793        let params = small_pix_params();
8794        assert_eq!(offered.parts_per_ssa, params.polys_per_ssa());
8795        assert_eq!(offered.shares_per_part, params.shares_per_poly());
8796        assert_eq!(
8797            offered.surplus_shares,
8798            params.surplus_shares(),
8799            "the surplus is priced in, so a server judging the quota needs it"
8800        );
8801        assert_eq!(offered.quota_per_ssa, small_params_quota());
8802        assert_eq!(
8803            capabilities,
8804            pix_offer().capabilities.0.bits(),
8805            "the capability bits travel as the peer sent them"
8806        );
8807
8808        sender.close_channel();
8809        transport.await??;
8810        handler.abort();
8811        Ok(())
8812    }
8813
8814    /// A peer offering nothing must be reported as offering nothing, not as offering zero: the two
8815    /// are different answers, and only one of them means "this Session is free if you allow it".
8816    #[test_log::test(tokio::test)]
8817    async fn a_peer_offering_no_incentivization_reports_no_offer() -> anyhow::Result<()> {
8818        let (seen_tx, seen_rx) = oneshot::channel();
8819        let seen_tx = Arc::new(std::sync::Mutex::new(Some(seen_tx)));
8820        let (admission, handler) = spawn_admission_handler(move |request| {
8821            if let Some(tx) = seen_tx.lock().unwrap().take() {
8822                let _ = tx.send(request.offered);
8823            }
8824            Ok(SessionAdmissionDecision::default())
8825        });
8826        let (mgr, sender, transport, _err_rx, _incoming) =
8827            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
8828
8829        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), no_pix_offer())
8830            .await?;
8831
8832        assert!(
8833            seen_rx.await.context("admission was never asked")?.is_none(),
8834            "no offer must read as no offer"
8835        );
8836
8837        sender.close_channel();
8838        transport.await??;
8839        handler.abort();
8840        Ok(())
8841    }
8842
8843    /// An offer this node cannot read is refused before the session server is troubled with it: no
8844    /// decision could rescue it, and the round trip would be spent to reach the same answer.
8845    #[test_log::test(tokio::test)]
8846    async fn an_unreadable_offer_is_refused_without_asking_the_session_server() -> anyhow::Result<()> {
8847        let asked = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8848        let asked_by_handler = asked.clone();
8849        let (admission, handler) = spawn_admission_handler(move |_| {
8850            asked_by_handler.fetch_add(1, Ordering::Relaxed);
8851            Ok(SessionAdmissionDecision::default())
8852        });
8853        let (mgr, sender, transport, err_rx, _incoming) =
8854            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
8855
8856        // Zero polynomials is outside the protocol range, so unpacking refuses it outright.
8857        let unreadable = StartInitiation {
8858            additional_data: into_initiation_additional_data(((LOCAL_PIX_SUITE as u64) << 62) | (128u64 << 40)),
8859            ..pix_offer()
8860        };
8861        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), unreadable)
8862            .await?;
8863
8864        let err = err_rx.await.context("send_message was never called")?;
8865        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
8866        assert_eq!(
8867            asked.load(Ordering::Relaxed),
8868            0,
8869            "an offer that cannot be read must not cost an admission round trip"
8870        );
8871        assert_eq!(mgr.num_active_sessions(), 0);
8872
8873        sender.close_channel();
8874        transport.await??;
8875        handler.abort();
8876        Ok(())
8877    }
8878
8879    /// The feature itself: one Exit, one Entry offering the same thing, two targets, two answers.
8880    ///
8881    /// Every other test here fixes the decision and varies the offer; this one fixes the offer and
8882    /// varies the target, which is the only thing #8376 actually asks for.
8883    #[test_log::test(tokio::test)]
8884    async fn two_targets_on_one_exit_are_admitted_on_different_terms() -> anyhow::Result<()> {
8885        let free_target = SessionTarget::TcpStream(SealedHost::Plain("10.0.0.1:80".parse()?));
8886        let paid_target = SessionTarget::TcpStream(SealedHost::Plain("10.0.0.2:80".parse()?));
8887
8888        let free_for = free_target.clone();
8889        let (admission, handler) = spawn_admission_handler(move |request| {
8890            Ok(if request.target == free_for {
8891                SessionAdmissionDecision::default().with_enforce_pix(false)
8892            } else {
8893                SessionAdmissionDecision::default().with_enforce_pix(true)
8894            })
8895        });
8896
8897        // The node demands PIX; only the rule for `free_target` waives it.
8898        let (mgr, sender, transport, err_rx, _incoming) =
8899            started_manager_admitting_small_params(1..=small_params_quota(), true, Some(admission))?;
8900
8901        // Same Entry offer both times — no PIX at all.
8902        mgr.handle_incoming_session_initiation(
8903            HoprPseudonym::random(),
8904            StartInitiation {
8905                target: free_target,
8906                ..no_pix_offer()
8907            },
8908        )
8909        .await?;
8910        assert_eq!(mgr.num_active_sessions(), 1, "the waived target must be served");
8911
8912        mgr.handle_incoming_session_initiation(
8913            HoprPseudonym::random(),
8914            StartInitiation {
8915                target: paid_target,
8916                challenge: MIN_CHALLENGE + 1,
8917                ..no_pix_offer()
8918            },
8919        )
8920        .await?;
8921        assert_eq!(
8922            mgr.num_active_sessions(),
8923            1,
8924            "the second target must be refused, leaving only the first session"
8925        );
8926
8927        let err = err_rx.await.context("send_message was never called")?;
8928        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
8929        assert_eq!(
8930            err.identifier,
8931            ErrorIdentifier::Challenge(MIN_CHALLENGE + 1),
8932            "the refusal must name the second initiation, not the admitted first"
8933        );
8934
8935        sender.close_channel();
8936        transport.await??;
8937        handler.abort();
8938        Ok(())
8939    }
8940
8941    /// A queue with no reader is the third transient failure, beside the timeout and the dropped
8942    /// reply. It is `Busy` for the same reason: nothing about the target was decided.
8943    ///
8944    /// Reachable only because every request goes through one shared sender — sending through a
8945    /// fresh clone would hand each call its own guaranteed slot and the queue would never be full.
8946    #[test_log::test(tokio::test)]
8947    async fn a_full_admission_queue_refuses_the_session_as_busy() -> anyhow::Result<()> {
8948        // No receiver task at all, so nothing is ever drained. `_rx` is held rather than dropped:
8949        // a closed channel is a different failure from a full one.
8950        let (admission, _rx) =
8951            futures::channel::mpsc::channel::<(SessionAdmissionRequest, crate::types::SessionAdmissionReply)>(0);
8952
8953        let (mgr, sender, transport, err_rx, _incoming) =
8954            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
8955
8956        // The first request takes the sender's guaranteed slot; the second finds the queue full.
8957        for challenge in [MIN_CHALLENGE, MIN_CHALLENGE + 1] {
8958            mgr.handle_incoming_session_initiation(
8959                HoprPseudonym::random(),
8960                StartInitiation {
8961                    challenge,
8962                    ..pix_offer()
8963                },
8964            )
8965            .await?;
8966        }
8967
8968        let err = err_rx.await.context("send_message was never called")?;
8969        assert_eq!(err.reason, StartErrorReason::Busy);
8970        assert_eq!(mgr.num_active_sessions(), 0);
8971
8972        sender.close_channel();
8973        transport.await??;
8974        Ok(())
8975    }
8976
8977    /// The empty-intersection case is a misconfiguration; this is the ordinary one. The narrowed
8978    /// window is a real range that simply sits above what the Entry offered, and the refusal must
8979    /// look the same.
8980    #[test_log::test(tokio::test)]
8981    async fn a_narrowed_window_that_excludes_the_offer_refuses_without_being_empty() -> anyhow::Result<()> {
8982        let quota = small_params_quota();
8983        let (admission, handler) = spawn_admission_handler(move |_| {
8984            Ok(SessionAdmissionDecision::default().with_pix_quota_range(quota + 1..=2 * quota))
8985        });
8986        let (mgr, sender, transport, err_rx, _incoming) =
8987            started_manager_admitting_small_params(1..=2 * quota, false, Some(admission))?;
8988
8989        // The intersection is `quota+1..=2*quota` — non-empty, and above the offer.
8990        let effective = mgr
8991            .cfg
8992            .pix_config
8993            .effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(quota + 1..=2 * quota));
8994        assert!(!effective.quota_range.is_empty(), "this case must not be the empty one");
8995
8996        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
8997            .await?;
8998
8999        let err = err_rx.await.context("send_message was never called")?;
9000        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
9001        assert_eq!(mgr.num_active_sessions(), 0);
9002
9003        sender.close_channel();
9004        transport.await??;
9005        handler.abort();
9006        Ok(())
9007    }
9008
9009    /// Narrowing does not only accept or reject — with dynamic batching it moves the batch the Exit
9010    /// asks for, which is what the Entry pays per deposit round trip.
9011    #[test]
9012    fn a_narrowed_window_can_change_the_selected_batch_size() {
9013        let cfg = IncomingSessionPixConfig {
9014            quota_range: 100..=400,
9015            supervision: SupervisorConfig {
9016                ssas_per_request: 4,
9017                allow_dynamic_ssa_batches: true,
9018                ..Default::default()
9019            },
9020            ..Default::default()
9021        };
9022
9023        // The node's own window accepts a single SSA of 100.
9024        assert_eq!(
9025            cfg.effective_for(&SessionAdmissionDecision::default())
9026                .ssa_batch_size_for_quota(100),
9027            Some(1)
9028        );
9029
9030        // Narrowed to a floor of 250, the smallest batch that reaches it is three.
9031        assert_eq!(
9032            cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(250..=400))
9033                .ssa_batch_size_for_quota(100),
9034            Some(3)
9035        );
9036
9037        // Narrowed to a window every multiple of the offer jumps over — three total 300 and four
9038        // total 400, with nothing in between — so no batch fits and the Session is refused.
9039        assert_eq!(
9040            cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(310..=390))
9041                .ssa_batch_size_for_quota(100),
9042            None
9043        );
9044    }
9045
9046    /// Admission is an Exit-side question. An Entry establishing a Session decided its own target,
9047    /// so putting it to the local session server would be asking a node to vet itself.
9048    #[test_log::test(tokio::test)]
9049    async fn an_outgoing_session_is_never_put_to_the_admission_hook() -> anyhow::Result<()> {
9050        let asked = Arc::new(std::sync::atomic::AtomicUsize::new(0));
9051        let asked_by_handler = asked.clone();
9052        let (admission, handler) = spawn_admission_handler(move |_| {
9053            asked_by_handler.fetch_add(1, Ordering::Relaxed);
9054            Ok(SessionAdmissionDecision::default())
9055        });
9056
9057        let mgr = SessionManager::new(SessionManagerConfig {
9058            initiation_timeout_base: Duration::from_millis(50),
9059            ..Default::default()
9060        });
9061
9062        let mut transport = MockMsgSender::new();
9063        transport
9064            .expect_send_message()
9065            .returning(|_, _| Box::pin(async { Ok(()) }));
9066        let (sender, transport_handle) = mock_packet_planning(transport);
9067        let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(1);
9068        mgr.start(sender.clone(), new_session_tx, None, Some(admission))?;
9069
9070        // No peer answers, so this times out — which is fine: the assertion is about what was asked
9071        // before the attempt died, not about it succeeding.
9072        let _ = mgr
9073            .new_session(
9074                (&hopr_api::types::crypto::keypairs::ChainKeypair::random()).into(),
9075                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9076                SessionClientConfig {
9077                    pseudonym: Some(HoprPseudonym::random()),
9078                    capabilities: Capability::Segmentation.into(),
9079                    surb_management: None,
9080                    ..Default::default()
9081                },
9082            )
9083            .await;
9084
9085        assert_eq!(
9086            asked.load(Ordering::Relaxed),
9087            0,
9088            "an outgoing session must not consult the admission hook"
9089        );
9090
9091        sender.close_channel();
9092        transport_handle.await??;
9093        handler.abort();
9094        Ok(())
9095    }
9096
9097    /// The issue's first case: a target served without payment on a node that otherwise demands it.
9098    #[test_log::test(tokio::test)]
9099    async fn a_decision_waiving_pix_admits_a_client_that_offered_none() -> anyhow::Result<()> {
9100        let (admission, handler) =
9101            spawn_admission_handler(|_| Ok(SessionAdmissionDecision::default().with_enforce_pix(false)));
9102        let (mgr, sender, transport, err_rx, _incoming) =
9103            started_manager_admitting_small_params(1..=small_params_quota(), true, Some(admission))?;
9104
9105        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), no_pix_offer())
9106            .await?;
9107
9108        assert_eq!(
9109            mgr.num_active_sessions(),
9110            1,
9111            "a waived target must be admitted even though the node enforces PIX"
9112        );
9113
9114        sender.close_channel();
9115        transport.await??;
9116        assert!(
9117            err_rx.await.is_err(),
9118            "no SessionError may be sent for an admitted session"
9119        );
9120        handler.abort();
9121        Ok(())
9122    }
9123
9124    /// And its mirror: a target that must pay on a node that otherwise does not ask.
9125    #[test_log::test(tokio::test)]
9126    async fn a_decision_demanding_pix_refuses_a_client_that_offered_none() -> anyhow::Result<()> {
9127        let (admission, handler) =
9128            spawn_admission_handler(|_| Ok(SessionAdmissionDecision::default().with_enforce_pix(true)));
9129        let (mgr, sender, transport, err_rx, _incoming) =
9130            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9131
9132        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), no_pix_offer())
9133            .await?;
9134
9135        let err = err_rx.await.context("send_message was never called")?;
9136        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
9137        assert_eq!(mgr.num_active_sessions(), 0, "no slot may be created for a refusal");
9138
9139        sender.close_channel();
9140        transport.await??;
9141        handler.abort();
9142        Ok(())
9143    }
9144
9145    /// A window narrowed past the client's offer refuses it even though the node's own window would
9146    /// have accepted it, which is the whole point of parameterizing admission by target.
9147    #[test_log::test(tokio::test)]
9148    async fn a_decision_narrowing_the_quota_past_the_offer_refuses_it() -> anyhow::Result<()> {
9149        let quota = small_params_quota();
9150        let (admission, handler) = spawn_admission_handler(move |_| {
9151            Ok(SessionAdmissionDecision::default().with_pix_quota_range(quota + 1..=u64::MAX))
9152        });
9153        let (mgr, sender, transport, err_rx, _incoming) =
9154            started_manager_admitting_small_params(1..=quota, false, Some(admission))?;
9155
9156        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9157            .await?;
9158
9159        let err = err_rx.await.context("send_message was never called")?;
9160        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
9161        assert_eq!(mgr.num_active_sessions(), 0);
9162
9163        sender.close_channel();
9164        transport.await??;
9165        handler.abort();
9166        Ok(())
9167    }
9168
9169    /// The same offer, and the same node, admitted once the decision leaves the window alone —
9170    /// which is what makes the refusal above attributable to the narrowing and nothing else.
9171    #[test_log::test(tokio::test)]
9172    async fn the_same_offer_is_admitted_when_the_decision_leaves_the_window_alone() -> anyhow::Result<()> {
9173        let quota = small_params_quota();
9174        let (admission, handler) = spawn_admission_handler(|_| Ok(SessionAdmissionDecision::default()));
9175        let (mgr, sender, transport, err_rx, _incoming) =
9176            started_manager_admitting_small_params(1..=quota, false, Some(admission))?;
9177
9178        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9179            .await?;
9180
9181        assert_eq!(mgr.num_active_sessions(), 1);
9182
9183        sender.close_channel();
9184        transport.await??;
9185        assert!(
9186            err_rx.await.is_err(),
9187            "no SessionError may be sent for an admitted session"
9188        );
9189        handler.abort();
9190        Ok(())
9191    }
9192
9193    /// A refusal is permanent and says so, so the peer does not retry a target this Exit will not
9194    /// serve however the request is phrased.
9195    #[test_log::test(tokio::test)]
9196    async fn a_refused_admission_is_reported_as_a_target_that_is_not_admitted() -> anyhow::Result<()> {
9197        let (admission, handler) = spawn_admission_handler(|_| Err("this exit does not serve that".into()));
9198        let (mgr, sender, transport, err_rx, _incoming) =
9199            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9200
9201        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9202            .await?;
9203
9204        let err = err_rx.await.context("send_message was never called")?;
9205        assert_eq!(err.reason, StartErrorReason::TargetNotAdmitted);
9206        assert_eq!(err.identifier, ErrorIdentifier::Challenge(MIN_CHALLENGE));
9207        assert_eq!(mgr.num_active_sessions(), 0, "no slot may be created for a refusal");
9208
9209        sender.close_channel();
9210        transport.await??;
9211        handler.abort();
9212        Ok(())
9213    }
9214
9215    /// A server that does not answer must not become a server that admits everything: the Session is
9216    /// refused, and as `Busy` rather than `TargetNotAdmitted`, since nothing about the target was
9217    /// decided and a retry may well succeed.
9218    #[test_log::test(tokio::test)]
9219    async fn an_admission_that_never_answers_refuses_the_session_as_busy() -> anyhow::Result<()> {
9220        // Holds every request without replying, which is what a wedged session server looks like.
9221        let (admission, mut rx) =
9222            futures::channel::mpsc::channel::<(SessionAdmissionRequest, crate::types::SessionAdmissionReply)>(4);
9223        let handler = tokio::task::spawn(async move {
9224            let mut held = Vec::new();
9225            while let Some(request) = rx.next().await {
9226                held.push(request);
9227            }
9228        });
9229
9230        let (mgr, sender, transport, err_rx, _incoming) =
9231            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9232
9233        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9234            .await?;
9235
9236        let err = err_rx.await.context("send_message was never called")?;
9237        assert_eq!(err.reason, StartErrorReason::Busy);
9238        assert_eq!(mgr.num_active_sessions(), 0);
9239
9240        sender.close_channel();
9241        transport.await??;
9242        handler.abort();
9243        Ok(())
9244    }
9245
9246    /// Same outcome when the server drops the reply instead of answering: a bug in it is still not a
9247    /// decision, and must not be read as one.
9248    #[test_log::test(tokio::test)]
9249    async fn an_admission_whose_reply_is_dropped_refuses_the_session_as_busy() -> anyhow::Result<()> {
9250        let (admission, mut rx) =
9251            futures::channel::mpsc::channel::<(SessionAdmissionRequest, crate::types::SessionAdmissionReply)>(4);
9252        let handler = tokio::task::spawn(async move {
9253            while let Some((_request, reply)) = rx.next().await {
9254                drop(reply);
9255            }
9256        });
9257
9258        let (mgr, sender, transport, err_rx, _incoming) =
9259            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9260
9261        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9262            .await?;
9263
9264        let err = err_rx.await.context("send_message was never called")?;
9265        assert_eq!(err.reason, StartErrorReason::Busy);
9266        assert_eq!(mgr.num_active_sessions(), 0);
9267
9268        sender.close_channel();
9269        transport.await??;
9270        handler.abort();
9271        Ok(())
9272    }
9273
9274    /// A node that installed no session server has nobody to ask, so it must behave exactly as it
9275    /// did before this hook existed rather than failing closed on an absent decision.
9276    #[test_log::test(tokio::test)]
9277    async fn a_manager_without_an_admission_sink_admits_on_its_own_terms() -> anyhow::Result<()> {
9278        let (mgr, sender, transport, err_rx, _incoming) =
9279            started_manager_admitting_small_params(1..=small_params_quota(), false, None)?;
9280
9281        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9282            .await?;
9283
9284        assert_eq!(
9285            mgr.num_active_sessions(),
9286            1,
9287            "an offer the node's own policy accepts must be admitted with no session server present"
9288        );
9289
9290        sender.close_channel();
9291        transport.await??;
9292        assert!(
9293            err_rx.await.is_err(),
9294            "no SessionError may be sent for an admitted session"
9295        );
9296        Ok(())
9297    }
9298
9299    /// The request the session server is handed must name the target it is being asked about, or the
9300    /// hook cannot do the one job it exists for.
9301    #[test_log::test(tokio::test)]
9302    async fn the_admission_request_carries_the_session_target() -> anyhow::Result<()> {
9303        let (seen_tx, seen_rx) = oneshot::channel();
9304        let seen_tx = Arc::new(std::sync::Mutex::new(Some(seen_tx)));
9305        let (admission, handler) = spawn_admission_handler(move |request| {
9306            if let Some(tx) = seen_tx.lock().unwrap().take() {
9307                let _ = tx.send((request.session_id, request.target.clone()));
9308            }
9309            Ok(SessionAdmissionDecision::default())
9310        });
9311        let (mgr, sender, transport, _err_rx, _incoming) =
9312            started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9313
9314        let pseudonym = HoprPseudonym::random();
9315        let offer = pix_offer();
9316        let expected_target = offer.target.clone();
9317        mgr.handle_incoming_session_initiation(pseudonym, offer).await?;
9318
9319        let (seen_id, seen_target) = seen_rx.await.context("admission was never asked")?;
9320        assert_eq!(seen_target, expected_target);
9321        assert_eq!(seen_id, pseudonym, "the request must name the session it would become");
9322
9323        sender.close_channel();
9324        transport.await??;
9325        handler.abort();
9326        Ok(())
9327    }
9328
9329    /// The wait is spent inside the peer's own initiation timeout, so a configuration that outlasts
9330    /// it is normalized rather than left to expire after the peer has already given up.
9331    #[test]
9332    fn the_admission_timeout_is_clamped_below_the_initiation_budget() {
9333        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9334            SessionManager::new(SessionManagerConfig {
9335                initiation_timeout_base: Duration::from_millis(100),
9336                session_admission_timeout: Duration::from_secs(3600),
9337                ..Default::default()
9338            });
9339
9340        assert_eq!(
9341            mgr.cfg.session_admission_timeout,
9342            initiation_timeout_max_one_way(Duration::from_millis(100), RoutingOptions::MAX_INTERMEDIATE_HOPS)
9343        );
9344    }
9345
9346    /// Dynamic admission chooses the least expensive batch that brings the Entry's total offer into
9347    /// the accepted range. A cap is still a cap: it may leave the offer unsatisfiable, and checked
9348    /// multiplication must make an overflowing candidate a refusal rather than a wrapped match.
9349    #[test]
9350    fn dynamic_ssa_batches_choose_the_smallest_satisfying_batch() {
9351        // Through `effective_for` with an empty decision, which is the no-session-server path and
9352        // so yields exactly the node's configured policy.
9353        let config = |quota_range, ssas_per_request| {
9354            IncomingSessionPixConfig {
9355                quota_range,
9356                supervision: SupervisorConfig {
9357                    ssas_per_request,
9358                    allow_dynamic_ssa_batches: true,
9359                    ..Default::default()
9360                },
9361                ..Default::default()
9362            }
9363            .effective_for(&SessionAdmissionDecision::default())
9364        };
9365
9366        assert_eq!(Some(1), config(100..=350, 4).ssa_batch_size_for_quota(100));
9367        assert_eq!(Some(3), config(250..=350, 4).ssa_batch_size_for_quota(100));
9368        assert_eq!(None, config(250..=350, 2).ssa_batch_size_for_quota(100));
9369        assert_eq!(
9370            None,
9371            config(150..=175, 4).ssa_batch_size_for_quota(100),
9372            "the first multiple jumps over the range and every later one is larger"
9373        );
9374        assert_eq!(None, config(100..=350, 4).ssa_batch_size_for_quota(400));
9375        assert_eq!(
9376            None,
9377            config(u64::MAX..=u64::MAX, 2).ssa_batch_size_for_quota(u64::MAX / 2 + 1),
9378            "an overflowing batch quota must not wrap into the accepted range"
9379        );
9380    }
9381
9382    /// Disabling dynamic admission restores both halves of the old contract: only the per-SSA quota
9383    /// is compared with `quota_range`, and an accepted offer uses the configured batch exactly.
9384    #[test]
9385    fn disabling_dynamic_ssa_batches_preserves_fixed_batch_admission() {
9386        let fixed = |quota_range| {
9387            IncomingSessionPixConfig {
9388                quota_range,
9389                supervision: SupervisorConfig {
9390                    ssas_per_request: 3,
9391                    allow_dynamic_ssa_batches: false,
9392                    ..Default::default()
9393                },
9394                ..Default::default()
9395            }
9396            .effective_for(&SessionAdmissionDecision::default())
9397        };
9398
9399        assert_eq!(
9400            None,
9401            fixed(250..=350).ssa_batch_size_for_quota(100),
9402            "fixed mode must not rescue a sub-range SSA even when three of them total 300"
9403        );
9404        assert_eq!(
9405            Some(3),
9406            fixed(100..=100).ssa_batch_size_for_quota(100),
9407            "an individually accepted SSA must retain the configured exact batch"
9408        );
9409    }
9410
9411    /// Admission reserves against the batch it selected for this Session, not the configured dynamic
9412    /// ceiling. Otherwise a ceiling of nine would charge every already-satisfactory one-SSA offer as
9413    /// nine and silently divide the number of Sessions the Exit can admit.
9414    #[test]
9415    fn live_cycle_reservation_uses_the_selected_batch_size() -> anyhow::Result<()> {
9416        let params = small_pix_params();
9417        let selected_batch = 2;
9418        let expected = cycle_budget_for(&params, selected_batch);
9419        let manager: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9420            SessionManager::new(SessionManagerConfig {
9421                pix_config: IncomingSessionPixConfig {
9422                    max_live_cycle_bytes: expected,
9423                    supervision: SupervisorConfig {
9424                        ssas_per_request: 4,
9425                        ..Default::default()
9426                    },
9427                    ..Default::default()
9428                },
9429                ..Default::default()
9430            });
9431
9432        let reservation = manager
9433            .reserve_cycle_budget(&params, selected_batch)
9434            .context("the selected batch should fit exactly")?;
9435        assert_eq!(expected, manager.live_cycle_bytes.load(Ordering::Relaxed));
9436        drop(reservation);
9437        assert_eq!(0, manager.live_cycle_bytes.load(Ordering::Relaxed));
9438
9439        Ok(())
9440    }
9441
9442    /// A non-PIX Session reserves nothing.
9443    ///
9444    /// `check_pix_params` hands back nominal parameters for a peer that offered no PIX at all, and
9445    /// charging on those would bill every plain Session for reconstructor state that will never
9446    /// exist — silently capping a node that does not run PIX.
9447    #[test_log::test(tokio::test)]
9448    async fn a_non_pix_session_does_not_touch_the_live_cycle_budget() -> anyhow::Result<()> {
9449        use hopr_utils::network_types::prelude::SealedHost;
9450
9451        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9452            SessionManager::new(SessionManagerConfig {
9453                pix_config: IncomingSessionPixConfig {
9454                    // Not enough for any PIX session at all; a plain one must be unaffected.
9455                    max_live_cycle_bytes: 1,
9456                    ..Default::default()
9457                },
9458                ..Default::default()
9459            });
9460
9461        let mut transport = MockMsgSender::new();
9462        transport
9463            .expect_send_message()
9464            .returning(|_, _| futures::future::ok(()).boxed());
9465        let (sender, _handle) = mock_packet_planning(transport);
9466        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
9467        let _notifications = tokio::spawn(async move {
9468            pin_mut!(new_session_rx);
9469            while let Some(_session) = new_session_rx.next().await {}
9470        });
9471        mgr.start(sender.clone(), new_session_tx, None, None)?;
9472
9473        mgr.handle_incoming_session_initiation(
9474            HoprPseudonym::random(),
9475            StartInitiation {
9476                challenge: MIN_CHALLENGE,
9477                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9478                capabilities: HoprSessionCapabilities::empty(),
9479                additional_data: 0,
9480            },
9481        )
9482        .await?;
9483
9484        assert_eq!(1, mgr.num_active_sessions());
9485        assert_eq!(0, mgr.live_cycle_bytes.load(Ordering::Relaxed));
9486
9487        sender.close_channel();
9488        _handle.await??;
9489        Ok(())
9490    }
9491
9492    /// Verifies the early `TooManySessions` return at the top of `new_session` (line 767).
9493    /// Unlike `session_manager_should_reject_new_session_when_max_sessions_reached`, which fills
9494    /// incoming slots and hits the slot-guard path at line 957, this test fills all `maximum_sessions`
9495    /// slots so that the `if self.cfg.maximum_sessions <= self.sessions.entry_count()` check fires
9496    /// before any message is sent.
9497    #[test_log::test(tokio::test)]
9498    async fn new_session_returns_too_many_sessions_when_cache_is_full() -> anyhow::Result<()> {
9499        use hopr_utils::network_types::prelude::SealedHost;
9500
9501        let cfg = SessionManagerConfig {
9502            maximum_sessions: 2,
9503            idle_timeout: Duration::from_secs(3600),
9504            ..Default::default()
9505        };
9506        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> = SessionManager::new(cfg);
9507
9508        let mut transport = MockMsgSender::new();
9509        // Two incoming sessions: first sends SessionEstablished, second sends SessionError (no slots).
9510        transport
9511            .expect_send_message()
9512            .times(2)
9513            .returning(|_, _| futures::future::ok(()).boxed());
9514
9515        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
9516        let _notifications = tokio::spawn(async move {
9517            pin_mut!(new_session_rx);
9518            while let Some(_session) = new_session_rx.next().await {}
9519        });
9520        let (sender, _handle) = mock_packet_planning(transport);
9521        mgr.start(sender.clone(), new_session_tx, None, None)?;
9522        assert!(mgr.is_started());
9523
9524        // Fill the cache with two incoming sessions (Exits).
9525        for i in 0..2 {
9526            let pseudonym = HoprPseudonym::random();
9527            mgr.handle_incoming_session_initiation(
9528                pseudonym,
9529                StartInitiation {
9530                    challenge: MIN_CHALLENGE + i as u64,
9531                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9532                    capabilities: HoprSessionCapabilities::empty(),
9533                    additional_data: 0,
9534                },
9535            )
9536            .await?;
9537        }
9538        assert_eq!(mgr.active_sessions().len(), 2);
9539        assert_eq!(mgr.num_active_sessions(), 2);
9540
9541        // Third outgoing call hits the early return before sending anything.
9542        let result = mgr
9543            .new_session(
9544                Address::from(&ChainKeypair::random()),
9545                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9546                SessionClientConfig {
9547                    surb_management: None,
9548                    ..Default::default()
9549                },
9550            )
9551            .await;
9552
9553        assert!(result.is_err());
9554        assert!(matches!(
9555            result.unwrap_err(),
9556            TransportSessionError::Manager(SessionManagerError::TooManySessions)
9557        ));
9558
9559        sender.close_channel();
9560        _handle.await??;
9561        Ok(())
9562    }
9563
9564    /// Verifies that `session_initiations` is cleaned up when `new_session` fails to
9565    /// send the StartSession message (e.g. the underlying channel is closed).
9566    #[test_log::test(tokio::test)]
9567    async fn new_session_removes_challenge_on_send_failure() -> anyhow::Result<()> {
9568        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9569            SessionManager::new(Default::default());
9570
9571        // Create a channel whose receiver is dropped immediately.  When the mock
9572        // transport tries to `send` over this channel the call will return an error,
9573        // which propagates up through `send_via_msg_sender` as
9574        // `TransportSessionError::packet_sending`.
9575        let (tx, rx) = futures::channel::mpsc::unbounded();
9576        drop(rx);
9577
9578        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
9579        let _notifications = tokio::spawn(async move {
9580            pin_mut!(new_session_rx);
9581            while let Some(_session) = new_session_rx.next().await {}
9582        });
9583        mgr.start(tx, new_session_tx, None, None)?;
9584        assert!(mgr.is_started());
9585
9586        // Verify that sending fails because the receiver is gone.
9587        let result = mgr
9588            .new_session(
9589                Address::from(&ChainKeypair::random()),
9590                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9591                SessionClientConfig {
9592                    surb_management: None,
9593                    ..Default::default()
9594                },
9595            )
9596            .await;
9597
9598        assert!(result.is_err());
9599        assert_eq!(mgr.num_active_sessions(), 0);
9600        // The challenge must have been removed from `session_initiations` even
9601        // though the send failed.
9602        assert_eq!(
9603            mgr.session_initiations.entry_count(),
9604            0,
9605            "session_initiations was not cleaned up after send failure"
9606        );
9607
9608        Ok(())
9609    }
9610
9611    /// Verifies that `session_initiations` is cleaned up when the session initiation
9612    /// times out waiting for a response (neither `SessionEstablished` nor
9613    /// `SessionError` arrives).
9614    #[test_log::test(tokio::test)]
9615    async fn new_session_removes_challenge_on_timeout() -> anyhow::Result<()> {
9616        let cfg = SessionManagerConfig {
9617            initiation_timeout_base: Duration::from_millis(100),
9618            ..Default::default()
9619        };
9620
9621        let alice_mgr = SessionManager::new(cfg);
9622        let bob_mgr = SessionManager::new(Default::default());
9623
9624        let bob_peer: Address = (&ChainKeypair::random()).into();
9625
9626        let mut alice_transport = MockMsgSender::new();
9627        let bob_transport = MockMsgSender::new();
9628
9629        // Alice sends the StartSession message; Bob never responds.
9630        alice_transport
9631            .expect_send_message()
9632            .once()
9633            .returning(|_, _| futures::future::ok(()).boxed());
9634
9635        let (alice_sender, _alice_handle) = mock_packet_planning(alice_transport);
9636        let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
9637        alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?;
9638        assert!(alice_mgr.is_started());
9639
9640        let (bob_sender, _bob_handle) = mock_packet_planning(bob_transport);
9641        let (new_session_tx_bob, _) = futures::channel::mpsc::channel(1024);
9642        bob_mgr.start(bob_sender.clone(), new_session_tx_bob, None, None)?;
9643        assert!(bob_mgr.is_started());
9644
9645        // Record how many entries are in `session_initiations` before the call.
9646        assert_eq!(alice_mgr.session_initiations.entry_count(), 0);
9647
9648        let result = alice_mgr
9649            .new_session(
9650                bob_peer,
9651                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9652                SessionClientConfig {
9653                    capabilities: None.into(),
9654                    pseudonym: None,
9655                    surb_management: None,
9656                    ..Default::default()
9657                },
9658            )
9659            .await;
9660
9661        assert!(matches!(result, Err(TransportSessionError::Timeout)));
9662        assert_eq!(alice_mgr.num_active_sessions(), 0);
9663        // The pending challenge must have been removed from `session_initiations`
9664        // after the timeout error propagated.
9665        assert_eq!(
9666            alice_mgr.session_initiations.entry_count(),
9667            0,
9668            "session_initiations was not cleaned up after timeout"
9669        );
9670
9671        Ok(())
9672    }
9673
9674    /// Convenience: a session data packet carrying `payload`.
9675    #[cfg(test)]
9676    fn session_data_packet(payload: &[u8]) -> anyhow::Result<ApplicationDataIn> {
9677        Ok(ApplicationDataIn {
9678            data: ApplicationData::new(SESSION_APPLICATION_TAG, payload)?,
9679            packet_info: Default::default(),
9680        })
9681    }
9682
9683    /// The manager sink type used by the dispatch drop tests, which never open a session and so
9684    /// never exercise the sink — its concrete type only needs to be nameable.
9685    #[cfg(test)]
9686    type TestManager =
9687        SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>>;
9688
9689    // Test B (fully deregistered): a data packet for a session id absent from the registry is a
9690    // benign, quiet drop counted as UnknownData — NOT an error, and NOT one ERROR log per packet.
9691    // Under the old behaviour this returned `Err(UnknownData)` and the caller logged it at ERROR,
9692    // which is what produced the per-packet spam during the incident.
9693    #[test_log::test(tokio::test)]
9694    async fn dispatching_to_an_unregistered_session_should_be_a_quiet_counted_drop() -> anyhow::Result<()> {
9695        let mgr: TestManager = SessionManager::new(Default::default());
9696
9697        let pseudonym = HoprPseudonym::random();
9698        let before = crate::counters::session_unknown_data_drop_count();
9699
9700        // N packets → N drops, each an Ok(Dropped(Unregistered)), no panic, no unbounded error.
9701        const N: usize = 5;
9702        for _ in 0..N {
9703            let result = mgr.dispatch_message(pseudonym, session_data_packet(b"test data")?);
9704            assert!(
9705                matches!(result, Ok(DispatchResult::Dropped(DropReason::Unregistered))),
9706                "unregistered-session packet must be a benign Dropped(Unregistered), got {result:?}"
9707            );
9708        }
9709
9710        // Counter advanced by at least N (>= rather than == because it is a process-global atomic
9711        // other tests in this binary may also touch).
9712        assert!(crate::counters::session_unknown_data_drop_count() >= before + N);
9713
9714        Ok(())
9715    }
9716
9717    // Test A (primary regression): a data packet for a session whose data sink has been dropped
9718    // while the slot is still registered must be dropped quietly as SinkClosed — NOT an error.
9719    // This is the "sending on a disconnected channel" ERROR from the incident. The bad-behaviour
9720    // assertion is the silent-vs-loud distinction: the result is Ok(Dropped(SinkClosed)), not Err,
9721    // and specifically not SinkFull (which would misattribute a teardown as backpressure).
9722    #[test_log::test(tokio::test)]
9723    async fn dispatching_to_a_session_whose_sink_closed_should_be_a_quiet_counted_drop() -> anyhow::Result<()> {
9724        let mgr: TestManager = SessionManager::new(Default::default());
9725
9726        // `pre_populate_session` registers the slot and immediately drops its receiver, so the
9727        // sink is closed while the slot is still present — exactly the incident's race.
9728        let pseudonym = HoprPseudonym::random();
9729        mgr.pre_populate_session(pseudonym, DestinationRouting::Return(pseudonym.into()));
9730
9731        let before = crate::counters::session_inbox_closed_drop_count();
9732        let result = mgr.dispatch_message(pseudonym, session_data_packet(b"after teardown")?);
9733
9734        assert!(
9735            matches!(result, Ok(DispatchResult::Dropped(DropReason::SinkClosed))),
9736            "closed-sink packet must be a benign Dropped(SinkClosed), not an error or SinkFull, got {result:?}"
9737        );
9738        assert!(crate::counters::session_inbox_closed_drop_count() > before);
9739
9740        Ok(())
9741    }
9742
9743    // Genuine backpressure (inbox full, reader alive but not keeping up) is distinct from a
9744    // teardown: it is Dropped(SinkFull) and counted in SESSION_INBOX_DROPS, not the closed counter.
9745    #[test_log::test(tokio::test)]
9746    async fn dispatching_to_a_full_session_inbox_should_be_a_backpressure_drop() -> anyhow::Result<()> {
9747        // Capacity 1 so a single unread packet fills the inbox deterministically.
9748        let cfg = SessionManagerConfig {
9749            session_forward_capacity: 1,
9750            ..Default::default()
9751        };
9752        let mgr: TestManager = SessionManager::new(cfg);
9753
9754        // Keep the receiver alive but never read it, so the channel is open but saturates.
9755        let pseudonym = HoprPseudonym::random();
9756        let _rx = mgr.pre_populate_session_with_receiver(pseudonym, DestinationRouting::Return(pseudonym.into()));
9757
9758        // First packet fills the single slot and is accepted.
9759        let accepted = mgr.dispatch_message(pseudonym, session_data_packet(b"fills the slot")?);
9760        assert!(
9761            matches!(accepted, Ok(DispatchResult::Processed)),
9762            "first packet should be accepted, got {accepted:?}"
9763        );
9764
9765        // Second packet finds the inbox full → backpressure drop.
9766        let before = crate::counters::session_inbox_drop_count();
9767        let overflow = mgr.dispatch_message(pseudonym, session_data_packet(b"overflows")?);
9768        assert!(
9769            matches!(overflow, Ok(DispatchResult::Dropped(DropReason::SinkFull))),
9770            "overflow packet must be a Dropped(SinkFull) backpressure drop, got {overflow:?}"
9771        );
9772        assert!(crate::counters::session_inbox_drop_count() > before);
9773
9774        Ok(())
9775    }
9776
9777    /// A packet reporting that some of the SURBs it carried were dropped on arrival.
9778    /// An Entry-side slot whose organic SURB gate is closed: a target of 100 against a believed
9779    /// level of 200. Anything that reopens it in the tests below did so on the counterparty's word.
9780    fn entry_slot_with_closed_gate(
9781        mgr: &TestManager,
9782        pseudonym: HoprPseudonym,
9783    ) -> anyhow::Result<crossfire::AsyncRx<crossfire::mpsc::Array<ApplicationDataIn>>> {
9784        let rx = mgr.pre_populate_session_with_receiver(
9785            pseudonym,
9786            DestinationRouting::Forward {
9787                destination: Box::new(Address::from(&ChainKeypair::random()).into()),
9788                pseudonym: Some(pseudonym),
9789                forward_options: RoutingOptions::Hops(1.try_into()?),
9790                return_options: None,
9791            },
9792        );
9793
9794        let slot = mgr
9795            .sessions
9796            .get(&pseudonym)
9797            .ok_or_else(|| anyhow::anyhow!("slot must exist"))?;
9798        slot.surb_mgmt.update(&SurbBalancerConfig {
9799            target_surb_buffer_size: 100,
9800            ..Default::default()
9801        });
9802        slot.surb_mgmt
9803            .buffer_level
9804            .store(200, std::sync::atomic::Ordering::Relaxed);
9805        anyhow::ensure!(
9806            0 == slot.surb_mgmt.organic_surbs_per_packet(),
9807            "precondition: the gate must start closed"
9808        );
9809
9810        Ok(rx)
9811    }
9812
9813    /// The organic SURB cap the slot's gate currently answers.
9814    fn organic_surbs(mgr: &TestManager, pseudonym: HoprPseudonym) -> Option<usize> {
9815        mgr.sessions
9816            .get(&pseudonym)
9817            .map(|slot| slot.surb_mgmt.organic_surbs_per_packet())
9818    }
9819
9820    fn session_packet_signalling(signals: PacketSignals) -> anyhow::Result<ApplicationDataIn> {
9821        let mut data = session_data_packet(b"reply")?;
9822        data.packet_info.signals_from_sender = signals;
9823        Ok(data)
9824    }
9825
9826    fn keep_alive_packet_signalling(
9827        session_id: SessionId,
9828        signals: PacketSignals,
9829    ) -> anyhow::Result<ApplicationDataIn> {
9830        Ok(ApplicationDataIn {
9831            data: ApplicationData::try_from(HoprStartProtocol::KeepAlive(KeepAliveMessage {
9832                session_id,
9833                flags: KeepAliveFlag::BalancerState.into(),
9834                additional_data: 0,
9835            }))?,
9836            packet_info: IncomingPacketInfo {
9837                signals_from_sender: signals,
9838                ..Default::default()
9839            },
9840        })
9841    }
9842
9843    /// The regression: a packet dropped for local backpressure still carries what the counterparty
9844    /// said about its own SURB supply. Recorded only once the Session reads the packet, that word is
9845    /// lost exactly when the inbox is saturated — leaving organic production shut off on an estimate
9846    /// the counterparty has just contradicted, and with nothing else able to reopen it.
9847    #[test_log::test(tokio::test)]
9848    async fn distress_should_be_recorded_even_when_the_packet_is_dropped() -> anyhow::Result<()> {
9849        // Capacity 1, so the second packet deterministically finds the inbox full.
9850        let mgr: TestManager = SessionManager::new(SessionManagerConfig {
9851            session_forward_capacity: 1,
9852            ..Default::default()
9853        });
9854        let pseudonym = HoprPseudonym::random();
9855        let _rx = entry_slot_with_closed_gate(&mgr, pseudonym)?;
9856
9857        // Fill the single slot; nothing reads it.
9858        mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignals::default())?)?;
9859
9860        let dropped = mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignal::SurbDistress.into())?)?;
9861        assert!(
9862            matches!(dropped, DispatchResult::Dropped(DropReason::SinkFull)),
9863            "the fixture must actually drop this packet, got {dropped:?}"
9864        );
9865        assert_eq!(
9866            Some(1),
9867            organic_surbs(&mgr, pseudonym),
9868            "a dropped packet still spoke for the counterparty"
9869        );
9870
9871        Ok(())
9872    }
9873
9874    /// Keep-alives are all a Session that has stopped exchanging data still sends, and they reach
9875    /// `handle_keep_alive` stripped of their packet info — so before this was recorded at dispatch,
9876    /// a quiet counterparty could not report distress at all.
9877    #[test_log::test(tokio::test)]
9878    async fn distress_should_be_recorded_from_a_keep_alive() -> anyhow::Result<()> {
9879        let mgr: TestManager = SessionManager::new(Default::default());
9880        let pseudonym = HoprPseudonym::random();
9881        let _rx = entry_slot_with_closed_gate(&mgr, pseudonym)?;
9882
9883        // The manager is not started, so the Start-protocol worker rejects this after the signal has
9884        // been taken from it. That is the property under test: the record does not depend on
9885        // anything downstream succeeding.
9886        let _ = mgr.dispatch_message(
9887            pseudonym,
9888            keep_alive_packet_signalling(pseudonym, PacketSignal::OutOfSurbs.into())?,
9889        );
9890
9891        assert_eq!(Some(1), organic_surbs(&mgr, pseudonym));
9892
9893        Ok(())
9894    }
9895
9896    /// Distress is a running state, not a latch: the counterparty recomputes it per packet and stops
9897    /// setting it once its pool recovers, so a clean packet has to close the gate again.
9898    #[test_log::test(tokio::test)]
9899    async fn a_clean_packet_should_clear_distress() -> anyhow::Result<()> {
9900        let mgr: TestManager = SessionManager::new(Default::default());
9901        let pseudonym = HoprPseudonym::random();
9902        let _rx = entry_slot_with_closed_gate(&mgr, pseudonym)?;
9903
9904        mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignal::SurbDistress.into())?)?;
9905        assert_eq!(Some(1), organic_surbs(&mgr, pseudonym), "precondition: distress is set");
9906
9907        mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignals::default())?)?;
9908        assert_eq!(Some(0), organic_surbs(&mgr, pseudonym));
9909
9910        Ok(())
9911    }
9912
9913    /// The gate governs SURBs this side mints *for* its counterparty, which only the initiator does.
9914    /// On an incoming Session the same signal describes our own supply coming back to us, and acting
9915    /// on it would let a peer talk this side into minting for a buffer it does not keep.
9916    #[test_log::test(tokio::test)]
9917    async fn distress_should_be_ignored_on_an_incoming_session() -> anyhow::Result<()> {
9918        let mgr: TestManager = SessionManager::new(Default::default());
9919        let pseudonym = HoprPseudonym::random();
9920        let _rx = mgr.pre_populate_session_with_receiver(pseudonym, DestinationRouting::Return(pseudonym.into()));
9921
9922        let slot = mgr
9923            .sessions
9924            .get(&pseudonym)
9925            .ok_or_else(|| anyhow::anyhow!("slot must exist"))?;
9926        slot.surb_mgmt.update(&SurbBalancerConfig {
9927            target_surb_buffer_size: 100,
9928            ..Default::default()
9929        });
9930        slot.surb_mgmt
9931            .buffer_level
9932            .store(200, std::sync::atomic::Ordering::Relaxed);
9933
9934        mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignal::OutOfSurbs.into())?)?;
9935
9936        assert_eq!(
9937            Some(0),
9938            organic_surbs(&mgr, pseudonym),
9939            "an incoming Session must not take the signal as licence to mint"
9940        );
9941
9942        Ok(())
9943    }
9944
9945    /// Verifies that closing an existing session returns `true` and removes the session from the manager.
9946    ///
9947    /// ## Steps
9948    /// 1. A `SessionManager` is started with a mock transport that accepts one outbound message.
9949    /// 2. `handle_incoming_session_initiation` is called to create a session — one active session confirmed.
9950    /// 3. `close_session` is called with the session's pseudonym — returns `true`.
9951    /// 4. `num_active_sessions` is 0, confirming the session was fully removed.
9952    #[test_log::test(tokio::test)]
9953    async fn session_manager_should_return_true_when_closing_existing_session() -> anyhow::Result<()> {
9954        use hopr_utils::network_types::prelude::SealedHost;
9955
9956        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9957            SessionManager::new(Default::default());
9958
9959        let mut transport = MockMsgSender::new();
9960        transport
9961            .expect_send_message()
9962            .once()
9963            .returning(|_, _| futures::future::ok(()).boxed());
9964
9965        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
9966        let _notifications = tokio::spawn(async move {
9967            pin_mut!(new_session_rx);
9968            while let Some(_session) = new_session_rx.next().await {}
9969        });
9970        let (sender, _handle) = mock_packet_planning(transport);
9971        mgr.start(sender.clone(), new_session_tx, None, None)?;
9972        assert!(mgr.is_started());
9973
9974        // Create a session
9975        let pseudonym = HoprPseudonym::random();
9976        mgr.handle_incoming_session_initiation(
9977            pseudonym,
9978            StartInitiation {
9979                challenge: MIN_CHALLENGE,
9980                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9981                capabilities: HoprSessionCapabilities::empty(),
9982                additional_data: 0,
9983            },
9984        )
9985        .await?;
9986
9987        // Verify session exists
9988        assert_eq!(mgr.active_sessions().len(), 1);
9989        assert_eq!(mgr.num_active_sessions(), 1);
9990
9991        // Close the session - should return true
9992        let result = mgr.close_session(&pseudonym);
9993        assert!(result, "closing existing session should return true");
9994
9995        // Verify session is closed
9996        assert_eq!(mgr.active_sessions().len(), 0);
9997        assert_eq!(mgr.num_active_sessions(), 0);
9998
9999        // Cleanup: close sender and await handle
10000        sender.close_channel();
10001        _handle.await??;
10002
10003        Ok(())
10004    }
10005
10006    /// `mark_return_path_degraded` must mark exactly the Sessions routed to the given destination
10007    /// **that run a return path**, and return their pseudonyms (the SURB-generation keys the caller
10008    /// bumps), leaving Sessions bound for other destinations — and one-way Sessions with no return
10009    /// path — untouched.
10010    #[test_log::test(tokio::test)]
10011    async fn session_manager_mark_return_path_degraded_should_return_the_marked_pseudonyms() -> anyhow::Result<()> {
10012        use std::collections::HashSet;
10013
10014        let mgr = SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
10015
10016        let dest_a: Address = (&ChainKeypair::random()).into();
10017        let dest_b: Address = (&ChainKeypair::random()).into();
10018
10019        let hops = || RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN);
10020        let forward_to =
10021            |addr: Address, ps: HoprPseudonym, return_options: Option<RoutingOptions>| DestinationRouting::Forward {
10022                destination: Box::new(addr.into()),
10023                pseudonym: Some(ps),
10024                forward_options: hops(),
10025                return_options,
10026            };
10027
10028        // Two Sessions route to `dest_a` with a return path, one to `dest_b`, and one one-way Session
10029        // (no return path) to `dest_a` that must be skipped.
10030        let (ps_a1, ps_a2, ps_b, ps_a_oneway) = (
10031            HoprPseudonym::random(),
10032            HoprPseudonym::random(),
10033            HoprPseudonym::random(),
10034            HoprPseudonym::random(),
10035        );
10036        mgr.pre_populate_session(ps_a1, forward_to(dest_a, ps_a1, Some(hops())));
10037        mgr.pre_populate_session(ps_a2, forward_to(dest_a, ps_a2, Some(hops())));
10038        mgr.pre_populate_session(ps_b, forward_to(dest_b, ps_b, Some(hops())));
10039        mgr.pre_populate_session(ps_a_oneway, forward_to(dest_a, ps_a_oneway, None));
10040        mgr.sessions.run_pending_tasks();
10041
10042        let node_a = hopr_api::types::internal::NodeId::Chain(dest_a);
10043        let marked: HashSet<_> = mgr
10044            .mark_return_path_degraded(&node_a, Duration::from_secs(30))
10045            .into_iter()
10046            .collect();
10047        assert_eq!(
10048            marked,
10049            HashSet::from([ps_a1, ps_a2]),
10050            "only return-path Sessions routed to dest_a must be marked; the one-way Session is skipped"
10051        );
10052
10053        // A destination nothing routes to marks nothing.
10054        let node_c = hopr_api::types::internal::NodeId::Chain((&ChainKeypair::random()).into());
10055        assert!(
10056            mgr.mark_return_path_degraded(&node_c, Duration::from_secs(30))
10057                .is_empty(),
10058            "a destination with no Sessions must return no pseudonyms"
10059        );
10060
10061        Ok(())
10062    }
10063
10064    /// Verifies that an Exit `KeepAlive` updates the SURB buffer level and counts as returned service.
10065    ///
10066    /// ## Steps
10067    /// 1. A session slot is manually inserted into Alice's manager with a known `SurbBalancerConfig` and an initial
10068    ///    buffer level of 100.
10069    /// 2. A `KeepAlive` message with `KeepAliveFlag::BalancerState` and `additional_data: 200` is constructed and
10070    ///    dispatched to Alice's manager via `dispatch_message`.
10071    /// 3. The manager processes the keep-alive asynchronously; the test polls until the slot's `buffer_level` reaches
10072    ///    200 (with a 1-second timeout).
10073    /// 4. The buffer level is confirmed to be 200 and the returned-packet counter advances once.
10074    #[test_log::test(tokio::test)]
10075    async fn session_manager_should_update_buffer_level_on_keep_alive_with_balancer_state_flag() -> anyhow::Result<()> {
10076        use std::sync::atomic::Ordering;
10077
10078        let alice_pseudonym = HoprPseudonym::random();
10079        let session_id = alice_pseudonym;
10080        let initial_buffer_level = 100u64;
10081        let new_buffer_level = 200u64;
10082
10083        let balancer_cfg = SurbBalancerConfig {
10084            target_surb_buffer_size: 1000,
10085            max_surbs_per_sec: 100,
10086            ..Default::default()
10087        };
10088
10089        let alice_mgr =
10090            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
10091
10092        let (new_session_tx, _) = futures::channel::mpsc::channel(1024);
10093        let (mock_sender, _) = futures::channel::mpsc::unbounded();
10094        let _ahs = alice_mgr.start(mock_sender, new_session_tx, None, None)?;
10095        assert!(alice_mgr.is_started());
10096
10097        let (dummy_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
10098        let peer_address: Address = (&ChainKeypair::random()).into();
10099        alice_mgr.sessions.insert(
10100            session_id,
10101            SessionSlot {
10102                session_tx: dummy_tx,
10103                routing_opts: DestinationRouting::Forward {
10104                    destination: Box::new(peer_address.into()),
10105                    pseudonym: Some(alice_pseudonym),
10106                    forward_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN),
10107                    return_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN).into(),
10108                },
10109                abort_handles: Default::default(),
10110                surb_mgmt: Arc::new(BalancerStateValues::from(balancer_cfg)),
10111                surb_estimator: Default::default(),
10112                current_ssa_state: Default::default(),
10113                pix_supervisor: Default::default(),
10114                pix_egress_gate: Default::default(),
10115                pix_fill: Default::default(),
10116                returned_packets: Default::default(),
10117                cycle_budget: None,
10118            },
10119        );
10120
10121        // Set initial buffer level
10122        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10123        session_slot
10124            .surb_mgmt
10125            .buffer_level
10126            .store(initial_buffer_level, Ordering::Relaxed);
10127        drop(session_slot);
10128
10129        // Verify initial buffer level
10130        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10131        assert_eq!(session_slot.surb_mgmt.buffer_level(), initial_buffer_level);
10132        drop(session_slot);
10133
10134        // Create keep-alive message with BalancerState flag
10135        let ka = KeepAliveMessage::<SessionId> {
10136            session_id,
10137            flags: KeepAliveFlag::BalancerState.into(),
10138            additional_data: new_buffer_level,
10139        };
10140        let app_data: ApplicationData = HoprStartProtocol::KeepAlive(ka).try_into()?;
10141        let app_data_in = ApplicationDataIn {
10142            data: app_data,
10143            packet_info: Default::default(),
10144        };
10145
10146        // Dispatch the keep-alive message
10147        alice_mgr.dispatch_message(alice_pseudonym, app_data_in)?;
10148
10149        // Poll until the background task has processed the keep-alive
10150        tokio::time::timeout(Duration::from_secs(1), async {
10151            loop {
10152                if let Some(slot) = alice_mgr.sessions.get(&session_id)
10153                    && slot.surb_mgmt.buffer_level() == new_buffer_level
10154                {
10155                    break;
10156                }
10157                tokio::time::sleep(Duration::from_millis(10)).await;
10158            }
10159        })
10160        .await
10161        .context("keep-alive BalancerState update timed out")?;
10162
10163        // Verify buffer level was updated
10164        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10165        assert_eq!(
10166            session_slot.surb_mgmt.buffer_level(),
10167            new_buffer_level,
10168            "buffer level should be updated via keep-alive with BalancerState flag"
10169        );
10170        assert_eq!(
10171            1,
10172            session_slot.returned_packets.load(Ordering::Relaxed),
10173            "an Exit keep-alive consumes a return SURB and must count toward the successor gate"
10174        );
10175
10176        Ok(())
10177    }
10178
10179    /// Verifies that a `KeepAlive` message with the `BalancerTarget` flag updates the session's
10180    /// target SURB buffer size in the manager.
10181    ///
10182    /// ## Steps
10183    /// 1. A session slot is manually inserted into Alice's manager with a known `SurbBalancerConfig` and
10184    ///    `target_surb_buffer_size: 1000`.
10185    /// 2. A `KeepAlive` message with `KeepAliveFlag::BalancerTarget` and `additional_data: 2000` is constructed and
10186    ///    dispatched via `dispatch_message`.
10187    /// 3. The manager processes the keep-alive asynchronously; the test polls until the slot's
10188    ///    `target_surb_buffer_size` reaches 2000 (with a 1-second timeout).
10189    /// 4. The target is confirmed to be 2000, proving the `BalancerTarget` flag updated it.
10190    #[test_log::test(tokio::test)]
10191    async fn session_manager_should_update_target_on_keep_alive_with_balancer_target_flag() -> anyhow::Result<()> {
10192        use std::sync::atomic::Ordering;
10193
10194        let alice_pseudonym = HoprPseudonym::random();
10195        let session_id = alice_pseudonym;
10196        let initial_target = 1000u64;
10197        let new_target = 2000u64;
10198
10199        let balancer_cfg = SurbBalancerConfig {
10200            target_surb_buffer_size: initial_target,
10201            max_surbs_per_sec: 100,
10202            ..Default::default()
10203        };
10204
10205        let alice_mgr =
10206            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
10207
10208        let (new_session_tx, _) = futures::channel::mpsc::channel(1024);
10209        let (mock_sender, _) = futures::channel::mpsc::unbounded();
10210        let _ahs = alice_mgr.start(mock_sender, new_session_tx, None, None)?;
10211        assert!(alice_mgr.is_started());
10212
10213        let (dummy_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
10214        alice_mgr.sessions.insert(
10215            session_id,
10216            SessionSlot {
10217                session_tx: dummy_tx,
10218                routing_opts: DestinationRouting::Return(SurbMatcher::Pseudonym(alice_pseudonym)),
10219                abort_handles: Default::default(),
10220                surb_mgmt: Arc::new(BalancerStateValues::from(balancer_cfg)),
10221                surb_estimator: Default::default(),
10222                current_ssa_state: Default::default(),
10223                pix_supervisor: Default::default(),
10224                pix_egress_gate: Default::default(),
10225                pix_fill: Default::default(),
10226                returned_packets: Default::default(),
10227                cycle_budget: None,
10228            },
10229        );
10230
10231        // Verify initial target
10232        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10233        assert_eq!(
10234            session_slot.surb_mgmt.controller_bounds().target(),
10235            initial_target,
10236            "initial target should be set"
10237        );
10238        drop(session_slot);
10239
10240        // Create keep-alive message with BalancerTarget flag
10241        let ka = KeepAliveMessage::<SessionId> {
10242            session_id,
10243            flags: KeepAliveFlag::BalancerTarget.into(),
10244            additional_data: new_target,
10245        };
10246        let app_data: ApplicationData = HoprStartProtocol::KeepAlive(ka).try_into()?;
10247        let app_data_in = ApplicationDataIn {
10248            data: app_data,
10249            packet_info: Default::default(),
10250        };
10251
10252        // Dispatch the keep-alive message
10253        alice_mgr.dispatch_message(alice_pseudonym, app_data_in)?;
10254
10255        // Poll until the background task has processed the keep-alive
10256        tokio::time::timeout(Duration::from_secs(1), async {
10257            loop {
10258                if let Some(slot) = alice_mgr.sessions.get(&session_id)
10259                    && slot.surb_mgmt.target_surb_buffer_size.load(Ordering::Relaxed) == new_target
10260                {
10261                    break;
10262                }
10263                tokio::time::sleep(Duration::from_millis(10)).await;
10264            }
10265        })
10266        .await
10267        .context("keep-alive BalancerTarget update timed out")?;
10268
10269        // Verify target was updated
10270        let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10271        assert_eq!(
10272            session_slot.surb_mgmt.target_surb_buffer_size.load(Ordering::Relaxed),
10273            new_target,
10274            "target buffer size should be updated via keep-alive with BalancerTarget flag"
10275        );
10276        assert_eq!(
10277            0,
10278            session_slot.returned_packets.load(Ordering::Relaxed),
10279            "an Entry keep-alive received on the Exit must not count as returned Exit service"
10280        );
10281
10282        Ok(())
10283    }
10284
10285    /// Verifies that a session is evicted after the `idle_timeout` fires, without needing an explicit close.
10286    ///
10287    /// ## Steps
10288    /// 1. A `SessionManager` is configured with `maximum_sessions: 1` and `idle_timeout: 100ms`.
10289    /// 2. `handle_incoming_session_initiation` creates one session — confirmed active.
10290    /// 3. The test sleeps 200ms (well past the 100ms timeout), then calls `sessions.run_pending_tasks()` to drive the
10291    ///    eviction timer.
10292    /// 4. `active_sessions` is empty, confirming the idle session was cleaned up without an explicit close call.
10293    #[test_log::test(tokio::test)]
10294    async fn session_manager_should_evict_idle_session_and_call_close_callback() -> anyhow::Result<()> {
10295        use hopr_utils::network_types::prelude::SealedHost;
10296
10297        let cfg = SessionManagerConfig {
10298            maximum_sessions: 1,
10299            idle_timeout: Duration::from_millis(100),
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(1)
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        // Wait for the session to expire (idle_timeout = 100ms)
10337        tokio::time::sleep(Duration::from_millis(200)).await;
10338        mgr.sessions.run_pending_tasks();
10339
10340        // Verify session was evicted (cache should be empty now)
10341        assert_eq!(
10342            mgr.active_sessions().len(),
10343            0,
10344            "idle session should be evicted after timeout"
10345        );
10346
10347        Ok(())
10348    }
10349
10350    /// Verifies that a second incoming session initiation is rejected (not evicted) when the manager
10351    /// is at `maximum_sessions` capacity with a long `idle_timeout`.
10352    ///
10353    /// ## Steps
10354    /// 1. A `SessionManager` is configured with `maximum_sessions: 1` and `idle_timeout: 3600s` (long enough that
10355    ///    eviction will not fire during the test).
10356    /// 2. `handle_incoming_session_initiation` creates session `X1` — confirmed active.
10357    /// 3. `handle_incoming_session_initiation` is called for session `X2` — the manager detects capacity is reached and
10358    ///    rejects the initiation internally (sends `SessionError`).
10359    /// 4. `active_sessions` still contains only `X1`; the first session was not evicted to make room.
10360    #[test_log::test(tokio::test)]
10361    async fn session_manager_should_reject_new_session_when_max_sessions_reached_no_eviction() -> anyhow::Result<()> {
10362        use hopr_utils::network_types::prelude::SealedHost;
10363
10364        // Create manager with max 1 session
10365        let cfg = SessionManagerConfig {
10366            maximum_sessions: 1,
10367            idle_timeout: Duration::from_secs(3600), // Long timeout so eviction doesn't happen
10368            ..Default::default()
10369        };
10370        let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10371            SessionManager::new(cfg);
10372
10373        let mut transport = MockMsgSender::new();
10374        transport
10375            .expect_send_message()
10376            .times(2)
10377            .returning(|_, _| futures::future::ok(()).boxed());
10378
10379        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
10380        let _notifications = tokio::spawn(async move {
10381            pin_mut!(new_session_rx);
10382            while let Some(_session) = new_session_rx.next().await {}
10383        });
10384        let (sender, _handle) = mock_packet_planning(transport);
10385        mgr.start(sender.clone(), new_session_tx, None, None)?;
10386        assert!(mgr.is_started());
10387
10388        // Create first session
10389        let pseudonym1 = HoprPseudonym::random();
10390        mgr.handle_incoming_session_initiation(
10391            pseudonym1,
10392            StartInitiation {
10393                challenge: MIN_CHALLENGE,
10394                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10395                capabilities: HoprSessionCapabilities::empty(),
10396                additional_data: 0,
10397            },
10398        )
10399        .await?;
10400
10401        // Verify first session exists
10402        assert_eq!(mgr.active_sessions().len(), 1);
10403
10404        // Try to create second session - should be rejected (not evicted)
10405        let pseudonym2 = HoprPseudonym::random();
10406        let _result = mgr
10407            .handle_incoming_session_initiation(
10408                pseudonym2,
10409                StartInitiation {
10410                    challenge: MIN_CHALLENGE,
10411                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10412                    capabilities: HoprSessionCapabilities::empty(),
10413                    additional_data: 0,
10414                },
10415            )
10416            .await;
10417
10418        // Should still have exactly 1 session (the first one)
10419        assert_eq!(
10420            mgr.active_sessions().len(),
10421            1,
10422            "should still have exactly one session - second session should be rejected"
10423        );
10424
10425        // The active session should be the first one (second was rejected)
10426        assert!(
10427            mgr.active_sessions().contains(&pseudonym1),
10428            "the first session should still be active"
10429        );
10430
10431        // Cleanup: close sender and await handle
10432        sender.close_channel();
10433        _handle.await??;
10434
10435        Ok(())
10436    }
10437
10438    /// Verifies that `new_session` rejects `UsePIX` when the return path has 0 intermediate hops.
10439    ///
10440    /// PIX shares are encrypted with the first relayer's ticket-challenge solution and carried
10441    /// in return-path SURBs. With 0 intermediate hops (a direct Exit→Entry SURB), there is no
10442    /// relayer to provide the challenge solution, so shares are never embedded — the ongoing
10443    /// PIX share delivery mechanism is dead and the Exit's quota is never replenished.
10444    #[test_log::test(tokio::test)]
10445    async fn new_session_rejects_usepix_with_zero_return_hops() -> anyhow::Result<()> {
10446        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10447            SessionManager::new(Default::default());
10448
10449        let mut transport = MockMsgSender::new();
10450        // The error happens before any message is sent, so expect_send_message should NOT fire.
10451        transport.expect_send_message().times(0);
10452
10453        let (sender, _handle) = mock_packet_planning(transport);
10454        let (new_session_tx, _) = futures::channel::mpsc::channel(1);
10455        mgr.start(sender.clone(), new_session_tx, None, None)?;
10456        assert!(mgr.is_started());
10457
10458        let result = mgr
10459            .new_session(
10460                Address::from(&ChainKeypair::random()),
10461                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10462                SessionClientConfig {
10463                    capabilities: Capability::UsePIX.into(),
10464                    surb_management: None,
10465                    forward_path_options: RoutingOptions::Hops(1.try_into()?),
10466                    return_path_options: RoutingOptions::Hops(0.try_into()?),
10467                    ..Default::default()
10468                },
10469            )
10470            .await;
10471
10472        let err = result.unwrap_err();
10473        let msg = format!("{err:?}");
10474        assert!(
10475            msg.contains("UsePIX requires at least 1 intermediate hop on the return path"),
10476            "expected return-path guard error, got: {msg}"
10477        );
10478
10479        assert_eq!(mgr.num_active_sessions(), 0);
10480        // No challenge slot was consumed because the validations run before insert_into_next_slot.
10481        assert_eq!(
10482            mgr.session_initiations.entry_count(),
10483            0,
10484            "session_initiations must remain empty when UsePIX is rejected"
10485        );
10486
10487        sender.close_channel();
10488        _handle.await??;
10489        Ok(())
10490    }
10491
10492    /// Verifies that `new_session` rejects `UsePIX` on a node with no `PixToolbox` installed.
10493    ///
10494    /// Since the announced dimensions are read off the installed generator rather than named by the
10495    /// caller, the presence of that generator is the *only* precondition left for an outgoing PIX
10496    /// Session — there is no longer a caller-supplied value whose absence would fail first. A node
10497    /// that advertised `UsePIX` without one would establish a Session it cannot produce a single
10498    /// share for.
10499    ///
10500    /// The return path carries 2 intermediate hops so the zero-hop guard above cannot be what
10501    /// rejects this, and the assertion names the toolbox error specifically for the same reason.
10502    #[test_log::test(tokio::test)]
10503    async fn new_session_rejects_usepix_when_no_pix_toolbox_is_installed() -> anyhow::Result<()> {
10504        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10505            SessionManager::new(Default::default());
10506
10507        let mut transport = MockMsgSender::new();
10508        // The error happens before any message is sent, so expect_send_message should NOT fire.
10509        transport.expect_send_message().times(0);
10510
10511        let (sender, _handle) = mock_packet_planning(transport);
10512        let (new_session_tx, _) = futures::channel::mpsc::channel(1);
10513        // `None` for the toolbox: this is a node that cannot serve PIX at all.
10514        mgr.start(sender.clone(), new_session_tx, None, None)?;
10515        assert!(mgr.is_started());
10516
10517        let result = mgr
10518            .new_session(
10519                Address::from(&ChainKeypair::random()),
10520                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10521                SessionClientConfig {
10522                    capabilities: Capability::UsePIX.into(),
10523                    surb_management: None,
10524                    forward_path_options: RoutingOptions::Hops(1.try_into()?),
10525                    return_path_options: RoutingOptions::Hops(2.try_into()?),
10526                    ..Default::default()
10527                },
10528            )
10529            .await;
10530
10531        let msg = format!("{:?}", result.unwrap_err());
10532        assert!(
10533            msg.contains("UsePIX requested but no PIX toolbox installed"),
10534            "expected missing-toolbox error, got: {msg}"
10535        );
10536
10537        assert_eq!(mgr.num_active_sessions(), 0);
10538        // No challenge slot was consumed because the validations run before insert_into_next_slot.
10539        assert_eq!(
10540            mgr.session_initiations.entry_count(),
10541            0,
10542            "session_initiations must remain empty when UsePIX is rejected for a missing toolbox"
10543        );
10544
10545        sender.close_channel();
10546        _handle.await??;
10547        Ok(())
10548    }
10549
10550    // ---------------------------------------------------------------------------
10551    // PIX protocol tests
10552    // ---------------------------------------------------------------------------
10553
10554    /// The fill fractions must be range-checked before anything computes with them.
10555    ///
10556    /// This validator runs *before* `validate_pix_supervision` on both paths that reach it:
10557    /// `SessionManager::start` calls it first, and in `HoprProtocolConfig` it is a field validator
10558    /// while the other is a schema-level one. So it cannot assume the ranges have already been
10559    /// checked — and it feeds `finish_fraction` straight into `Duration::mul_f64`, which panics on a
10560    /// `NaN`, on a negative, and on a product the monotonic clock cannot hold. A single
10561    /// `finish_fraction: .nan` in a YAML file would therefore abort the node at load instead of being
10562    /// reported as the configuration error it is.
10563    ///
10564    /// The finite out-of-range values do not panic, which is why they are here too: they compute a
10565    /// floor that is wrong in the permissive direction, and nothing downstream ever says so.
10566    #[test]
10567    fn fill_fractions_are_rejected_before_the_ceiling_arithmetic_runs() {
10568        let with_fill = |fill: crate::supervision::PixFillConfig| IncomingSessionPixConfig {
10569            supervision: SupervisorConfig {
10570                fill,
10571                ..Default::default()
10572            },
10573            ..Default::default()
10574        };
10575
10576        for finish_fraction in [f64::NAN, -1.0, f64::INFINITY, 2.0] {
10577            let cfg = with_fill(crate::supervision::PixFillConfig {
10578                finish_fraction,
10579                ..Default::default()
10580            });
10581            let outcome = validate_incoming_session_pix_config(&cfg, MAX_ASSUMED_SESSION_PACKET_RATE);
10582            assert!(
10583                matches!(&outcome, Err(TransportSessionError::InvalidConfig(msg)) if msg.contains("fill.finish_fraction")),
10584                "a finish_fraction of {finish_fraction} must be refused rather than multiplied, got {outcome:?}"
10585            );
10586        }
10587
10588        for loss_margin in [f64::NAN, -0.5, 1.0] {
10589            let cfg = with_fill(crate::supervision::PixFillConfig {
10590                loss_margin,
10591                ..Default::default()
10592            });
10593            let outcome = validate_incoming_session_pix_config(&cfg, MAX_ASSUMED_SESSION_PACKET_RATE);
10594            assert!(
10595                matches!(&outcome, Err(TransportSessionError::InvalidConfig(msg)) if msg.contains("fill.loss_margin")),
10596                "a loss_margin of {loss_margin} must be refused, got {outcome:?}"
10597            );
10598        }
10599
10600        validate_incoming_session_pix_config(&IncomingSessionPixConfig::default(), MAX_ASSUMED_SESSION_PACKET_RATE)
10601            .expect("the shipped defaults must still validate");
10602    }
10603
10604    /// A zero recovery deadline must be named as such on both fill paths, rather than reaching the
10605    /// fill arithmetic and being reported as an infinite rate requirement.
10606    #[test]
10607    fn a_zero_recovery_deadline_is_refused_as_zero_with_and_without_fill() {
10608        for enabled in [true, false] {
10609            let cfg = IncomingSessionPixConfig {
10610                supervision: SupervisorConfig {
10611                    max_recovery_time: Duration::ZERO,
10612                    fill: crate::supervision::PixFillConfig {
10613                        enabled,
10614                        ..Default::default()
10615                    },
10616                    ..Default::default()
10617                },
10618                ..Default::default()
10619            };
10620            let outcome = validate_incoming_session_pix_config(&cfg, MAX_ASSUMED_SESSION_PACKET_RATE);
10621            assert!(
10622                matches!(&outcome, Err(TransportSessionError::InvalidConfig(msg)) if msg.contains("max_recovery_time must be non-zero")),
10623                "with fill enabled = {enabled}, a zero deadline must be refused as zero, got {outcome:?}"
10624            );
10625        }
10626    }
10627
10628    /// The fill heartbeat is clamped at *both* ends for a programmatically assembled config.
10629    ///
10630    /// The upper clamp is the one every other supervisor duration takes. The lower one is what stops
10631    /// a zero period, which `validate_pix_supervision` rejects and nothing in this crate calls: with
10632    /// it, `FillRate::as_packets_per_sec` reports zero for the heartbeat, so the planner's floor is
10633    /// zero, both heartbeat branches of `FillPlanner::bound` return a zero-period rate, and
10634    /// `PixFillControl::apply` hands `set_rate_per_unit(1, 1µs)` to the controller — which
10635    /// `RateController::MIN_DELAY` turns into 10 000 packets/s. The `max_rate` ceiling never sees it,
10636    /// so the one bound on this node's self-generated egress would be bypassed by the one field left
10637    /// unbounded below.
10638    #[test]
10639    fn programmatic_fill_heartbeat_is_clamped_to_a_positive_period() {
10640        let normalized = |heartbeat: Duration| {
10641            let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10642                SessionManager::new(SessionManagerConfig {
10643                    pix_config: IncomingSessionPixConfig {
10644                        supervision: SupervisorConfig {
10645                            fill: crate::supervision::PixFillConfig {
10646                                heartbeat,
10647                                ..Default::default()
10648                            },
10649                            ..Default::default()
10650                        },
10651                        ..Default::default()
10652                    },
10653                    ..Default::default()
10654                });
10655            mgr.cfg.pix_config.supervision.fill.heartbeat
10656        };
10657
10658        // The exact dual of the `max_rate` ceiling, so the two clamps cannot drift apart.
10659        let floor = Duration::from_secs(1) / crate::supervision::MAX_FILL_RATE;
10660        assert_eq!(
10661            floor,
10662            normalized(Duration::ZERO),
10663            "a zero heartbeat must be raised to the period of MAX_FILL_RATE"
10664        );
10665        assert_eq!(
10666            crate::supervision::MAX_SUPERVISOR_DURATION,
10667            normalized(Duration::MAX),
10668            "an unrepresentable heartbeat must be lowered to the same cap every other deadline takes"
10669        );
10670
10671        // Which is the property the clamp exists for: a rate the planner can divide by.
10672        assert!(
10673            FillRate::once_per(normalized(Duration::ZERO)).as_packets_per_sec() > 0.0,
10674            "the clamped heartbeat must be a rate the planner can compare against its ceiling"
10675        );
10676    }
10677
10678    /// Supervisor durations a programmatic caller supplies must be clamped, not trusted.
10679    ///
10680    /// `validate_pix_supervision` rejects these, but nothing in this crate calls `validate` — a
10681    /// `SessionManagerConfig` assembled in code reaches the supervisor exactly as written. And the
10682    /// failure is silent in the worst direction: `Instant::checked_add` returns `None` for a duration
10683    /// the monotonic clock cannot represent, and every phase reads an absent deadline as *no
10684    /// deadline*, so the over-large value disables the rule rather than relaxing it.
10685    ///
10686    /// The two batch-scaled deadlines are clamped by what is actually armed, so a batch of `n` leaves
10687    /// each of them at a cap `n` times smaller.
10688    #[test]
10689    fn programmatic_supervisor_durations_are_clamped_to_a_representable_deadline() {
10690        const BATCH: usize = 4;
10691        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10692            SessionManager::new(SessionManagerConfig {
10693                pix_config: IncomingSessionPixConfig {
10694                    supervision: SupervisorConfig {
10695                        ssas_per_request: BATCH,
10696                        max_ssa_delivery_time: Duration::MAX,
10697                        max_deposit_wait: Duration::MAX,
10698                        max_recovery_idle: Duration::MAX,
10699                        max_recovery_time: Duration::MAX,
10700                        tombstone_retention_window: Duration::MAX,
10701                        ..Default::default()
10702                    },
10703                    ..Default::default()
10704                },
10705                ..Default::default()
10706            });
10707
10708        let sup = &mgr.cfg.pix_config.supervision;
10709        let cap = crate::supervision::MAX_SUPERVISOR_DURATION;
10710        let per_cycle_cap = cap / BATCH as u32;
10711
10712        assert_eq!(per_cycle_cap, sup.max_ssa_delivery_time);
10713        assert_eq!(per_cycle_cap, sup.max_deposit_wait);
10714        assert_eq!(cap, sup.max_recovery_time);
10715        assert_eq!(cap, sup.tombstone_retention_window);
10716
10717        // `max_recovery_idle` is bounded by something tighter than the clock: it must stay strictly
10718        // under the reconstructor's `unused_verifier_lifetime`, or the supervisor waits on a cycle
10719        // whose state was reclaimed long before. Normalized against the default reconstructor, which
10720        // is the one `SessionManager::new` can see; `start` re-checks against the installed one.
10721        let lifetime = hopr_protocol_pix::SsaReconstructorConfig::default().unused_verifier_lifetime;
10722        assert!(
10723            sup.max_recovery_idle < lifetime,
10724            "clamped max_recovery_idle ({:?}) must stay under the reconstructor lifetime ({lifetime:?})",
10725            sup.max_recovery_idle
10726        );
10727        crate::supervision::validate_pix_supervision(sup, &hopr_protocol_pix::SsaReconstructorConfig::default())
10728            .expect("a normalized config must satisfy every cross-component invariant, not only representability");
10729
10730        // Which is exactly the condition that makes every deadline representable.
10731        let now = std::time::Instant::now();
10732        for dur in [
10733            crate::supervision::scaled_deadline(sup.max_ssa_delivery_time, sup.ssas_per_request),
10734            crate::supervision::scaled_deadline(sup.max_deposit_wait, sup.ssas_per_request),
10735            sup.max_recovery_idle,
10736            sup.max_recovery_time,
10737            sup.tombstone_retention_window,
10738        ] {
10739            assert!(
10740                now.checked_add(dur).is_some(),
10741                "a clamped duration ({dur:?}) must still produce a deadline"
10742            );
10743        }
10744    }
10745
10746    /// Normalizing against the default reconstructor is not enough when a caller installs a
10747    /// different one.
10748    ///
10749    /// `SessionManager::new` cannot see the reconstructor — it arrives with the toolbox at `start` —
10750    /// so its clamp can only assume the defaults. A caller pairing a shorter
10751    /// `unused_verifier_lifetime` with a supervisor config that was valid against the defaults gets
10752    /// a supervisor that outlives the state it is waiting on. That must be an error rather than a
10753    /// silent clamp: at `start` both halves were chosen deliberately, and overriding one of them
10754    /// would be answering a question the caller did not ask.
10755    #[test]
10756    fn start_rejects_a_supervisor_config_the_installed_reconstructor_cannot_support() -> anyhow::Result<()> {
10757        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
10758
10759        // Valid against the defaults — 10 min sits well under the default 30 min lifetime — and
10760        // therefore untouched by the constructor's clamp.
10761        let cfg = SessionManagerConfig {
10762            pix_config: IncomingSessionPixConfig {
10763                supervision: SupervisorConfig {
10764                    max_recovery_idle: Duration::from_secs(600),
10765                    ..Default::default()
10766                },
10767                ..Default::default()
10768            },
10769            ..Default::default()
10770        };
10771        crate::supervision::validate_pix_supervision(&cfg.pix_config.supervision, &SsaReconstructorConfig::default())
10772            .expect("the fixture must be valid against the default reconstructor");
10773
10774        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> = SessionManager::new(cfg);
10775
10776        // But the reconstructor actually installed reclaims a cycle after 60 s.
10777        let (pix_toolbox, _pix_events) = PixToolbox::new(
10778            SsaShareGenerator::new(SsaGeneratorConfig::default()).into(),
10779            SsaReconstructor::new(SsaReconstructorConfig {
10780                unused_verifier_lifetime: Duration::from_secs(60),
10781                ..Default::default()
10782            })
10783            .into(),
10784        );
10785
10786        let (tx, _rx) = futures::channel::mpsc::unbounded();
10787        let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(1);
10788        let started = mgr.start(tx, new_session_tx, Some(pix_toolbox), None);
10789
10790        assert!(
10791            matches!(started, Err(TransportSessionError::InvalidConfig(_))),
10792            "starting with a reconstructor that cannot support the supervisor config must fail, got {started:?}"
10793        );
10794
10795        Ok(())
10796    }
10797
10798    /// A programmatically assembled manager must enforce the same incoming-PIX cross-field rules
10799    /// as the serialized transport configuration before it starts accepting Sessions.
10800    #[allow(clippy::reversed_empty_ranges)]
10801    #[test_log::test(tokio::test)]
10802    async fn start_rejects_each_invalid_incoming_pix_cross_field_combination() -> anyhow::Result<()> {
10803        let valid = IncomingSessionPixConfig::default();
10804        let widest = max_cycle_budget_for_quota(*valid.quota_range.end(), valid.supervision.ssas_per_request);
10805        let invalid_configs = [
10806            (
10807                "empty quota range",
10808                IncomingSessionPixConfig {
10809                    quota_range: 100..=10,
10810                    ..valid.clone()
10811                },
10812                "quota_range",
10813            ),
10814            (
10815                "recovery deadline below one accepted cycle",
10816                IncomingSessionPixConfig {
10817                    supervision: SupervisorConfig {
10818                        max_recovery_time: Duration::from_secs(1),
10819                        ..valid.supervision.clone()
10820                    },
10821                    ..valid.clone()
10822                },
10823                "max_recovery_time",
10824            ),
10825            (
10826                "live-cycle budget below one accepted cycle",
10827                IncomingSessionPixConfig {
10828                    max_live_cycle_bytes: widest - 1,
10829                    ..valid
10830                },
10831                "max_live_cycle_bytes",
10832            ),
10833        ];
10834
10835        for (case, pix_config, expected_field) in invalid_configs {
10836            let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10837                SessionManager::new(SessionManagerConfig {
10838                    pix_config,
10839                    ..Default::default()
10840                });
10841            let (pix_toolbox, _pix_events) = PixToolbox::new(
10842                SsaShareGenerator::new(SsaGeneratorConfig::default()).into(),
10843                SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
10844            );
10845            let (tx, _rx) = futures::channel::mpsc::unbounded();
10846            let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(1);
10847
10848            match mgr.start(tx, new_session_tx, Some(pix_toolbox), None) {
10849                Err(TransportSessionError::InvalidConfig(message)) => assert!(
10850                    message.contains(expected_field),
10851                    "{case} should identify {expected_field}, got: {message}"
10852                ),
10853                Err(error) => anyhow::bail!("{case} returned the wrong error: {error}"),
10854                Ok(_) => anyhow::bail!("{case} was accepted"),
10855            }
10856        }
10857
10858        Ok(())
10859    }
10860
10861    /// A rejected PIX configuration must not consume the manager's one-shot start state.
10862    ///
10863    /// Validation is fallible and happens before any worker is spawned, so callers must be able to
10864    /// correct the toolbox (or start without PIX) and retry the same manager. Otherwise `start`
10865    /// reports an ordinary configuration error while leaving `is_started() == false`, but the
10866    /// already-filled message-sender lock makes every retry fail with `AlreadyStarted`.
10867    ///
10868    /// On a runtime because the retry now gets far enough to spawn the manager's workers, which is
10869    /// the whole point — before the fix it returned `AlreadyStarted` before reaching them.
10870    #[test_log::test(tokio::test)]
10871    async fn rejected_pix_start_does_not_poison_a_retry() -> anyhow::Result<()> {
10872        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
10873
10874        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10875            SessionManager::new(SessionManagerConfig {
10876                pix_config: IncomingSessionPixConfig {
10877                    supervision: SupervisorConfig {
10878                        max_recovery_idle: Duration::from_secs(600),
10879                        ..Default::default()
10880                    },
10881                    ..Default::default()
10882                },
10883                ..Default::default()
10884            });
10885
10886        let (invalid_pix, _pix_events) = PixToolbox::new(
10887            SsaShareGenerator::new(SsaGeneratorConfig::default()).into(),
10888            SsaReconstructor::new(SsaReconstructorConfig {
10889                unused_verifier_lifetime: Duration::from_secs(60),
10890                ..Default::default()
10891            })
10892            .into(),
10893        );
10894        let (first_tx, _first_rx) = futures::channel::mpsc::unbounded();
10895        let (first_notifier, _first_notifications) = futures::channel::mpsc::channel(1);
10896        assert!(matches!(
10897            mgr.start(first_tx, first_notifier, Some(invalid_pix), None),
10898            Err(TransportSessionError::InvalidConfig(_))
10899        ));
10900        assert!(
10901            !mgr.is_started(),
10902            "a rejected start must not report the manager as running"
10903        );
10904
10905        let (retry_tx, _retry_rx) = futures::channel::mpsc::unbounded();
10906        let (retry_notifier, _retry_notifications) = futures::channel::mpsc::channel(1);
10907        let retry = mgr.start(retry_tx, retry_notifier, None, None);
10908        assert!(
10909            retry.is_ok(),
10910            "a configuration error must leave the manager retryable, got {retry:?}"
10911        );
10912
10913        Ok(())
10914    }
10915
10916    /// Making every duration representable is not sufficient if normalization leaves a supervisor
10917    /// waiting after its paired reconstructor has already discarded the cycle. A programmatic
10918    /// caller using the default reconstructor must receive a configuration that still satisfies the
10919    /// same cross-component lifetime invariants as a file-loaded configuration.
10920    #[test]
10921    fn programmatic_clamping_preserves_reconstructor_lifetime_invariants() {
10922        let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10923            SessionManager::new(SessionManagerConfig {
10924                pix_config: IncomingSessionPixConfig {
10925                    supervision: SupervisorConfig {
10926                        max_recovery_idle: Duration::MAX,
10927                        ..Default::default()
10928                    },
10929                    ..Default::default()
10930                },
10931                ..Default::default()
10932            });
10933
10934        crate::supervision::validate_pix_supervision(
10935            &mgr.cfg.pix_config.supervision,
10936            &SsaReconstructorConfig::default(),
10937        )
10938        .expect(
10939            "normalizing a programmatic config must not leave recovery-idle beyond the reconstructor's state lifetime",
10940        );
10941    }
10942
10943    /// Verifies that an incoming Session whose PIX quota cannot reach the acceptable range within
10944    /// the configured dynamic batch ceiling is rejected with
10945    /// `StartErrorReason::UnacceptablePixParams`.
10946    ///
10947    /// ## Steps
10948    /// 1. Bob's manager accepts exactly two times the Entry's per-SSA quota, but leaves the dynamic batch ceiling at
10949    ///    its default of one.
10950    /// 2. The test offers valid PIX dimensions whose quota would therefore need a batch of two.
10951    /// 3. `handle_incoming_session_initiation` is called with `Capability::UsePIX` and the out-of-range quota.
10952    /// 4. Bob's manager sends a `SessionError` back to the peer with reason `UnacceptablePixParams`.
10953    /// 5. The test receives the error on a one-shot channel and asserts `err.reason == UnacceptablePixParams` and
10954    ///    `err.challenge == MIN_CHALLENGE`.
10955    /// 6. `num_active_sessions` is 0, confirming no session slot was created.
10956    #[test_log::test(tokio::test)]
10957    async fn incoming_session_with_unacceptable_pix_quota_is_rejected() -> anyhow::Result<()> {
10958        use std::sync::Arc;
10959
10960        use hopr_protocol_pix::SsaReconstructorConfig;
10961        use hopr_protocol_start::{StartErrorReason, StartInitiation};
10962        use tokio::sync::oneshot;
10963
10964        let params = small_pix_params();
10965        let quota_per_ssa = pix_params_to_quota(&params);
10966        let pix_toolbox = pix_toolbox_with_pool(
10967            SsaShareGenerator::new(SsaGeneratorConfig {
10968                polynomials_per_ssa: 2,
10969                threshold: 2,
10970                surplus_shares: TEST_SURPLUS_SHARES,
10971            })
10972            .into(),
10973            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
10974        );
10975
10976        let mgr = SessionManager::new(SessionManagerConfig {
10977            pix_config: IncomingSessionPixConfig {
10978                quota_range: 2 * quota_per_ssa..=2 * quota_per_ssa,
10979                ..Default::default()
10980            },
10981            ..Default::default()
10982        });
10983
10984        let mut bob_transport = MockMsgSender::new();
10985        let (tx, rx) = oneshot::channel();
10986        let tx = Arc::new(std::sync::Mutex::new(Some(tx)));
10987
10988        bob_transport.expect_send_message().returning(move |_, data| {
10989            let tx = tx.clone();
10990            Box::pin(async move {
10991                if let Ok(HoprStartProtocol::SessionError(err)) =
10992                    HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
10993                    && let Some(tx) = tx.lock().unwrap().take()
10994                {
10995                    let _ = tx.send(err);
10996                }
10997                Ok(())
10998            })
10999        });
11000
11001        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11002        let (new_session_tx, _) = futures::channel::mpsc::channel(1);
11003        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11004
11005        let alice_pseudonym = HoprPseudonym::random();
11006
11007        mgr.handle_incoming_session_initiation(
11008            alice_pseudonym,
11009            StartInitiation {
11010                challenge: MIN_CHALLENGE,
11011                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11012                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11013                additional_data: into_initiation_additional_data(params.into_additional_data(0)),
11014            },
11015        )
11016        .await?;
11017
11018        let err = rx.await.context("send_message was never called")?;
11019        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
11020        assert_eq!(err.identifier, ErrorIdentifier::Challenge(MIN_CHALLENGE));
11021        assert_eq!(mgr.num_active_sessions(), 0);
11022
11023        bob_sender.close_channel();
11024        bob_handle.await??;
11025        Ok(())
11026    }
11027
11028    /// Verifies that an incoming session initiation that does not declare `UsePIX` capability is
11029    /// rejected when PIX is enforced on the responder.
11030    ///
11031    /// ## Steps
11032    /// 1. Bob's manager is configured with `pix_config.enforce_pix: true`, requiring all incoming sessions to opt into
11033    ///    PIX.
11034    /// 2. The incoming initiation carries `Capability::Segmentation` only (no `UsePIX`).
11035    /// 3. `handle_incoming_session_initiation` is called; Bob's manager detects the missing `UsePIX` capability and
11036    ///    sends a `SessionError` with `UnacceptablePixParams`.
11037    /// 4. The test receives the error and asserts `err.reason == UnacceptablePixParams`.
11038    /// 5. `num_active_sessions` is 0, confirming no session slot was created.
11039    #[test_log::test(tokio::test)]
11040    async fn incoming_session_without_usepix_is_rejected_when_pix_enforced() -> anyhow::Result<()> {
11041        use std::sync::Arc;
11042
11043        use hopr_protocol_start::{StartErrorReason, StartInitiation};
11044        use tokio::sync::oneshot;
11045
11046        let mgr = SessionManager::new(SessionManagerConfig {
11047            pix_config: IncomingSessionPixConfig {
11048                enforce_pix: true,
11049                ..Default::default()
11050            },
11051            ..Default::default()
11052        });
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        mgr.start(bob_sender.clone(), new_session_tx, None, None)?;
11074
11075        let alice_pseudonym = HoprPseudonym::random();
11076
11077        mgr.handle_incoming_session_initiation(
11078            alice_pseudonym,
11079            StartInitiation {
11080                challenge: MIN_CHALLENGE,
11081                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11082                capabilities: HoprSessionCapabilities(Capability::Segmentation.into()),
11083                additional_data: 0,
11084            },
11085        )
11086        .await?;
11087
11088        let err = rx.await.context("send_message was never called")?;
11089        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
11090        assert_eq!(err.identifier, ErrorIdentifier::Challenge(MIN_CHALLENGE));
11091        assert_eq!(mgr.num_active_sessions(), 0);
11092
11093        bob_sender.close_channel();
11094        bob_handle.await??;
11095        Ok(())
11096    }
11097
11098    /// A node with no `PixToolbox` must refuse an incoming session that asks for `UsePIX`, rather
11099    /// than establish one it cannot run the PIX state machine for.
11100    ///
11101    /// This is the guard at the top of `handle_incoming_session_initiation`, and it was untested:
11102    /// the integration test named for it never negotiated PIX at all, so the absent toolbox was not
11103    /// the operative cause of anything it observed.
11104    ///
11105    /// The offered dimensions have to be ones `check_pix_params` *accepts*, and the test asserts
11106    /// that before exercising the handler. The fallthrough from a rejected `check_pix_params` emits
11107    /// the identical `StartErrorReason::UnacceptablePixParams` under the identical
11108    /// `ErrorIdentifier::Challenge` and creates no session either, so with unacceptable dimensions
11109    /// nothing here could tell the two refusals apart — the guard could be deleted outright and
11110    /// this would still pass.
11111    #[test_log::test(tokio::test)]
11112    async fn incoming_usepix_session_is_rejected_when_no_pix_toolbox_is_installed() -> anyhow::Result<()> {
11113        use std::sync::Arc;
11114
11115        use hopr_protocol_start::{StartErrorReason, StartInitiation};
11116        use tokio::sync::oneshot;
11117
11118        // PIX not enforced, and the default `quota_range` ends exactly on the quota the default
11119        // dimensions imply, so the offer below sits inside it.
11120        let mgr = SessionManager::new(SessionManagerConfig::default());
11121
11122        let mut bob_transport = MockMsgSender::new();
11123        let (tx, rx) = oneshot::channel();
11124        let tx = Arc::new(std::sync::Mutex::new(Some(tx)));
11125
11126        bob_transport.expect_send_message().returning(move |_, data| {
11127            let tx = tx.clone();
11128            Box::pin(async move {
11129                if let Ok(HoprStartProtocol::SessionError(err)) =
11130                    HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
11131                    && let Some(tx) = tx.lock().unwrap().take()
11132                {
11133                    let _ = tx.send(err);
11134                }
11135                Ok(())
11136            })
11137        });
11138
11139        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11140        let (new_session_tx, _) = futures::channel::mpsc::channel(1);
11141        // No toolbox — the third argument is what a relay that does not participate in PIX gets.
11142        mgr.start(bob_sender.clone(), new_session_tx, None, None)?;
11143
11144        let req = StartInitiation {
11145            challenge: MIN_CHALLENGE,
11146            target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11147            capabilities: HoprSessionCapabilities(Capability::Segmentation | Capability::UsePIX),
11148            additional_data: into_initiation_additional_data(DEFAULT_PIX_PARAMS.into_additional_data(0)),
11149        };
11150
11151        // What makes the missing toolbox the sole remaining cause of the refusal below.
11152        assert!(
11153            decode_and_check(&mgr, &req).is_some(),
11154            "the offered dimensions must be acceptable, or the refusal cannot be attributed to the guard"
11155        );
11156
11157        mgr.handle_incoming_session_initiation(HoprPseudonym::random(), req)
11158            .await?;
11159
11160        let err = rx.await.context("send_message was never called")?;
11161        assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
11162        assert_eq!(err.identifier, ErrorIdentifier::Challenge(MIN_CHALLENGE));
11163        assert_eq!(mgr.num_active_sessions(), 0, "no slot may be created for a refusal");
11164
11165        bob_sender.close_channel();
11166        bob_handle.await??;
11167        Ok(())
11168    }
11169
11170    /// Verifies that the exit/responder (Bob) rejects an `SsaCommit` for a session that has no PIX
11171    /// state — i.e., the SSA commit is delivered with a session ID that Bob does not hold.
11172    ///
11173    /// ## Steps
11174    /// 1. Bob's manager is started with a `PixToolbox` and a PIX quota config. Alice's session initiation is processed
11175    ///    normally via `handle_incoming_session_initiation`, establishing a session with PIX state.
11176    /// 2. `handle_ssa_commit` is called with a completely different (random) session ID — one that Bob's manager does
11177    ///    not have.
11178    /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::NonExistingSession)`,
11179    ///    confirming the exit rejects commits for unknown sessions.
11180    #[test_log::test(tokio::test)]
11181    async fn exit_rejects_ssa_commit_when_session_has_no_pix_state() -> anyhow::Result<()> {
11182        use std::collections::HashMap;
11183
11184        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11185        use hopr_protocol_start::StartInitiation;
11186
11187        let ssa_gen_config = SsaGeneratorConfig {
11188            polynomials_per_ssa: 2,
11189            threshold: 2,
11190            surplus_shares: 1,
11191        };
11192        let ssa_rec_config = SsaReconstructorConfig::default();
11193
11194        let (pix_toolbox, _) = PixToolbox::new(
11195            SsaShareGenerator::new(ssa_gen_config).into(),
11196            SsaReconstructor::new(ssa_rec_config).into(),
11197        );
11198
11199        let mgr = SessionManager::new(SessionManagerConfig {
11200            pix_config: IncomingSessionPixConfig {
11201                quota_range: 0..=1024 * 1024 * 1024,
11202                ..Default::default()
11203            },
11204            ..Default::default()
11205        });
11206
11207        let mut bob_transport = MockMsgSender::new();
11208        bob_transport
11209            .expect_send_message()
11210            .returning(|_, _| Box::pin(async { Ok(()) }));
11211        // handle_incoming_session_initiation sends SessionEstablished + SsaRequest.
11212        bob_transport
11213            .expect_send_message()
11214            .returning(|_, _| Box::pin(async { Ok(()) }));
11215
11216        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11217        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11218        let _notifications = tokio::spawn(async move {
11219            pin_mut!(new_session_rx);
11220            while let Some(_session) = new_session_rx.next().await {}
11221        });
11222        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11223
11224        let alice_pseudonym = HoprPseudonym::random();
11225
11226        mgr.handle_incoming_session_initiation(
11227            alice_pseudonym,
11228            StartInitiation {
11229                challenge: MIN_CHALLENGE,
11230                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11231                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11232                additional_data: small_pix_additional_data(),
11233            },
11234        )
11235        .await?;
11236
11237        let result = mgr
11238            .handle_ssa_commit(
11239                HoprPseudonym::random(),
11240                SsaClientCommitmentMessage {
11241                    session_id: alice_pseudonym,
11242                    ssa_index: SsaIndex::MIN,
11243                    coefficient_index: 0,
11244                    commitment_proof: None,
11245                    coefficient_commitments: HashMap::new(),
11246                },
11247            )
11248            .await;
11249
11250        bob_sender.close_channel();
11251        bob_handle.await??;
11252
11253        assert!(result.is_err());
11254        assert!(matches!(
11255            result.unwrap_err(),
11256            TransportSessionError::Manager(SessionManagerError::NonExistingSession)
11257        ));
11258
11259        Ok(())
11260    }
11261
11262    /// Verifies that a session is closed on the very first `UnverifiableShares` PIX event.
11263    ///
11264    /// An event means a whole polynomial failed to open its commitment, which already dooms the
11265    /// cycle, so there is nothing to tolerate and nothing to configure. See
11266    /// `SessionPixSupervisor::on_unverifiable_shares` for the reasoning.
11267    ///
11268    /// ## Steps
11269    /// 1. Bob's manager is started with a `PixToolbox` and a PIX quota config. Alice's session initiation is processed
11270    ///    via `handle_incoming_session_initiation`.
11271    /// 2. One `UnverifiableShares` event is dispatched for the session's `SsaId`.
11272    /// 3. The session is closed: `active_sessions` is empty and `num_active_sessions` is 0, confirming the supervisor
11273    ///    closed it.
11274    #[test_log::test(tokio::test)]
11275    async fn session_is_closed_on_the_first_unverifiable_share() -> anyhow::Result<()> {
11276        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11277        use hopr_protocol_start::StartInitiation;
11278
11279        let ssa_gen_config = SsaGeneratorConfig {
11280            polynomials_per_ssa: 2,
11281            threshold: 2,
11282            surplus_shares: 1,
11283        };
11284
11285        let (pix_toolbox, _) = PixToolbox::new(
11286            SsaShareGenerator::new(ssa_gen_config).into(),
11287            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11288        );
11289
11290        let mgr = SessionManager::new(SessionManagerConfig {
11291            pix_config: IncomingSessionPixConfig {
11292                quota_range: 0..=1024 * 1024 * 1024,
11293                ..Default::default()
11294            },
11295            ..Default::default()
11296        });
11297
11298        let mut bob_transport = MockMsgSender::new();
11299        bob_transport
11300            .expect_send_message()
11301            .returning(|_, _| Box::pin(async { Ok(()) }));
11302        // handle_incoming_session_initiation sends SessionEstablished + SsaRequest.
11303        bob_transport
11304            .expect_send_message()
11305            .returning(|_, _| Box::pin(async { Ok(()) }));
11306
11307        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11308        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11309        let _notifications = tokio::spawn(async move {
11310            pin_mut!(new_session_rx);
11311            while let Some(_session) = new_session_rx.next().await {}
11312        });
11313        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11314
11315        let alice_pseudonym = HoprPseudonym::random();
11316
11317        mgr.handle_incoming_session_initiation(
11318            alice_pseudonym,
11319            StartInitiation {
11320                challenge: MIN_CHALLENGE,
11321                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11322                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11323                additional_data: small_pix_additional_data(),
11324            },
11325        )
11326        .await?;
11327
11328        assert_eq!(mgr.num_active_sessions(), 1, "the session must start out open");
11329
11330        let ssa_id = SsaId::new(alice_pseudonym, SsaIndex::MIN);
11331        mgr.dispatch_pix_event(HoprSessionInPixEvent::UnverifiableShares {
11332            ssa_id,
11333            observed_total: 1,
11334            peer: crate::supervision::test_peer(),
11335        })
11336        .await?;
11337
11338        // The supervisor decides, and its `Close` reaches the driver over a channel, so the close
11339        // is observed rather than assumed.
11340        tokio::time::timeout(Duration::from_secs(1), async {
11341            while mgr.num_active_sessions() > 0 {
11342                tokio::time::sleep(Duration::from_millis(10)).await;
11343            }
11344        })
11345        .await
11346        .context("session must be closed by the first unverifiable share")?;
11347
11348        assert!(mgr.active_sessions().is_empty());
11349
11350        bob_sender.close_channel();
11351        bob_handle.await??;
11352        Ok(())
11353    }
11354
11355    // Four tests lived here, all asserting how many `SsaRequest` messages an `SsaAlmostRecovered` or
11356    // `SsaRecovered` event produced: `exit_requests_new_ssa_after_almost_recovered_event`,
11357    // `exit_ignores_stale_ssa_almost_recovered_event`,
11358    // `exit_handles_concurrent_almost_and_full_recovery_for_same_ssa` and
11359    // `exit_requests_new_ssa_on_recovery_when_not_already_pipelined`.
11360    //
11361    // That decision is the supervisor's now, and each of them has a direct counterpart against the
11362    // state machine: `almost_recovered_while_recovering_requests_next_once`,
11363    // `almost_recovered_while_awaiting_deposit_defers_request`,
11364    // `recovered_with_prior_early_event_does_not_fallback` and
11365    // `recovered_without_prior_early_event_falls_back_to_request`. Reaching the same logic through
11366    // an event channel, an action channel and a spawned driver only added scheduling noise — and
11367    // three of the four asserted a pipelining that no longer happens on an unfunded SSA, which is
11368    // the deliberate change: the Exit does not commit to another SSA before the current one is paid
11369    // for.
11370    //
11371    // What those tests did cover that the state machine cannot is the wiring itself, in both
11372    // directions. Actions reaching the wire is `the_opening_ssa_request_follows_session_established`
11373    // below; events reaching the supervisor is `session_is_closed_on_the_first_unverifiable_share`.
11374
11375    /// Verifies that the supervisor's opening `RequestSsa` reaches the wire, and does so *after* the
11376    /// `SessionEstablished` that publishes the Session.
11377    ///
11378    /// The ordering is why the supervisor is spawned where it is. An `SsaRequest` arriving first
11379    /// would reference a Session the Entry has not been told exists.
11380    #[test_log::test(tokio::test)]
11381    async fn the_opening_ssa_request_follows_session_established() -> anyhow::Result<()> {
11382        use std::sync::Arc;
11383
11384        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11385        use hopr_protocol_start::StartInitiation;
11386
11387        let pix_toolbox = pix_toolbox_with_pool(
11388            SsaShareGenerator::new(SsaGeneratorConfig {
11389                polynomials_per_ssa: 2,
11390                threshold: 2,
11391                surplus_shares: 1,
11392            })
11393            .into(),
11394            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11395        );
11396
11397        let mgr = SessionManager::new(SessionManagerConfig {
11398            pix_config: IncomingSessionPixConfig {
11399                quota_range: 0..=1024 * 1024 * 1024,
11400                ..Default::default()
11401            },
11402            ..Default::default()
11403        });
11404
11405        // Records the kind of every outbound Start message, in order.
11406        let sent = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new()));
11407        let sent_clone = sent.clone();
11408        let mut bob_transport = MockMsgSender::new();
11409        bob_transport.expect_send_message().returning(move |_, data| {
11410            let sent_clone = sent_clone.clone();
11411            Box::pin(async move {
11412                match HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text) {
11413                    Ok(HoprStartProtocol::SessionEstablished(_)) => sent_clone.lock().unwrap().push("established"),
11414                    Ok(HoprStartProtocol::SsaRequest(req)) => {
11415                        assert_eq!(
11416                            req.dimensions().expect("SsaRequest params must be in range"),
11417                            PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE).expect("valid"),
11418                            "SsaRequest must carry the negotiated dimensions, surplus included"
11419                        );
11420                        assert_eq!(
11421                            req.commitments.keys().copied().collect::<Vec<_>>(),
11422                            [SsaIndex::MIN],
11423                            "the opening request must commit to the first SSA index"
11424                        );
11425                        sent_clone.lock().unwrap().push("ssa_request");
11426                    }
11427                    _ => {}
11428                }
11429                Ok(())
11430            })
11431        });
11432
11433        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11434        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11435        let _notifications = tokio::spawn(async move {
11436            pin_mut!(new_session_rx);
11437            while let Some(_session) = new_session_rx.next().await {}
11438        });
11439        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11440
11441        let alice_pseudonym = HoprPseudonym::random();
11442        mgr.handle_incoming_session_initiation(
11443            alice_pseudonym,
11444            StartInitiation {
11445                challenge: MIN_CHALLENGE,
11446                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11447                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11448                additional_data: small_pix_additional_data(),
11449            },
11450        )
11451        .await?;
11452
11453        tokio::time::timeout(Duration::from_secs(1), async {
11454            while sent.lock().unwrap().len() < 2 {
11455                tokio::time::sleep(Duration::from_millis(10)).await;
11456            }
11457        })
11458        .await
11459        .context("the supervisor's opening SsaRequest never reached the wire")?;
11460
11461        assert_eq!(
11462            sent.lock().unwrap().as_slice(),
11463            ["established", "ssa_request"],
11464            "the Entry must learn the Session exists before it is asked to commit to an SSA"
11465        );
11466
11467        bob_sender.close_channel();
11468        bob_handle.await??;
11469        Ok(())
11470    }
11471
11472    /// A pool that does not supply deposit data must take the Session with it, and name itself.
11473    ///
11474    /// The data only ever travels in the `SsaRequest` that carries the commitments, so an Entry that
11475    /// needs it and does not get it cannot ask for it later — it would simply never deposit, and the
11476    /// Session would die on the Exit's own commitment deadline, blamed on the peer. Failing here
11477    /// costs one Session that was not going to work, and points at the local pool instead: the Entry
11478    /// is told with `UnacceptablePixParams` rather than the supervisor's generic `Unknown`, and this
11479    /// node records [`ClosureReason::MissingDepositData`] rather than `PixFailure`.
11480    ///
11481    /// The event stream is dropped rather than left unanswered, which reaches the same failure
11482    /// through the listener check instead of [`DEPOSIT_DATA_REQUEST_TIMEOUT`] — the same error, the
11483    /// same close, three seconds sooner. What the collection itself does with a pool that answers
11484    /// badly is covered by the `request_deposit_data` tests below.
11485    #[test_log::test(tokio::test)]
11486    async fn missing_deposit_data_closes_the_session_and_names_the_pool() -> anyhow::Result<()> {
11487        use std::sync::Arc;
11488
11489        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11490        use hopr_protocol_start::StartInitiation;
11491
11492        // Deliberately *not* `pix_toolbox_with_pool`: nothing answers, which is the case under test.
11493        let (pix_toolbox, pix_events) = PixToolbox::new(
11494            SsaShareGenerator::new(SsaGeneratorConfig {
11495                polynomials_per_ssa: 2,
11496                threshold: 2,
11497                surplus_shares: 1,
11498            })
11499            .into(),
11500            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11501        );
11502        drop(pix_events);
11503
11504        let mgr = SessionManager::new(SessionManagerConfig {
11505            pix_config: IncomingSessionPixConfig {
11506                quota_range: 0..=1024 * 1024 * 1024,
11507                ..Default::default()
11508            },
11509            ..Default::default()
11510        });
11511
11512        // Records the kind of every outbound Start message, with the reason of any SessionError.
11513        let sent = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
11514        let sent_clone = sent.clone();
11515        let mut bob_transport = MockMsgSender::new();
11516        bob_transport.expect_send_message().returning(move |_, data| {
11517            let sent_clone = sent_clone.clone();
11518            Box::pin(async move {
11519                match HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text) {
11520                    Ok(HoprStartProtocol::SessionEstablished(_)) => {
11521                        sent_clone.lock().unwrap().push("established".into())
11522                    }
11523                    Ok(HoprStartProtocol::SsaRequest(_)) => sent_clone.lock().unwrap().push("ssa_request".into()),
11524                    Ok(HoprStartProtocol::SessionError(err)) => {
11525                        sent_clone.lock().unwrap().push(format!("error:{}", err.reason))
11526                    }
11527                    _ => {}
11528                }
11529                Ok(())
11530            })
11531        });
11532
11533        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11534        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11535        let _notifications = tokio::spawn(async move {
11536            pin_mut!(new_session_rx);
11537            while let Some(_session) = new_session_rx.next().await {}
11538        });
11539        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11540
11541        let alice_pseudonym = HoprPseudonym::random();
11542        mgr.handle_incoming_session_initiation(
11543            alice_pseudonym,
11544            StartInitiation {
11545                challenge: MIN_CHALLENGE,
11546                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11547                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11548                additional_data: small_pix_additional_data(),
11549            },
11550        )
11551        .await?;
11552
11553        timeout(Duration::from_secs(1), async {
11554            while mgr.num_active_sessions() > 0 {
11555                tokio::time::sleep(Duration::from_millis(10)).await;
11556            }
11557        })
11558        .await
11559        .context("the Session must not survive a pool that cannot supply deposit data")?;
11560
11561        let sent = sent.lock().unwrap().clone();
11562        assert!(
11563            !sent.iter().any(|m| m == "ssa_request"),
11564            "no SsaRequest may go on the wire without the deposit data it has to carry, got {sent:?}"
11565        );
11566        assert!(
11567            sent.iter()
11568                .any(|m| m == &format!("error:{}", StartErrorReason::UnacceptablePixParams)),
11569            "the Entry must be told with a reason that names the failure, got {sent:?}"
11570        );
11571
11572        bob_sender.close_channel();
11573        bob_handle.await??;
11574        Ok(())
11575    }
11576
11577    // `pipelined_ssa_preserves_earlier_deposit_deadline` lived here. It asserted that
11578    // `PixKillSwitch(1)` and `PixKillSwitch(2)` were independent entries in the `AbortableList`,
11579    // which is no longer a thing the manager can observe: per-SSA deadlines belong to the
11580    // supervisor. The invariant moved with them, to
11581    // `supervision::supervisor::tests::pipelining_a_second_ssa_leaves_the_first_ones_deadlines_alone`,
11582    // where it can compare the deadline instants rather than just the presence of two handles.
11583
11584    /// Verifies that an explicit `close_session` leaves no reconstructor state behind for an SSA
11585    /// cycle that was still in flight.
11586    ///
11587    /// Retirement used to be an explicit sweep over every index the Session had ever used; it is
11588    /// now a consequence of aborting the action driver, which drops the guards it holds. This
11589    /// exercises one cycle because that is all a mock transport can get in flight — pipelining a
11590    /// second needs a funded first, and funding arrives from the chain. The multi-cycle case is
11591    /// covered where the guards are: `protocols/pix`'s guard tests and the supervisor's own suite.
11592    #[test_log::test(tokio::test)]
11593    async fn close_session_retires_in_flight_ssa_cycles() -> anyhow::Result<()> {
11594        let pix_toolbox = pix_toolbox_with_pool(
11595            SsaShareGenerator::new(SsaGeneratorConfig {
11596                polynomials_per_ssa: 2,
11597                threshold: 2,
11598                surplus_shares: 1,
11599            })
11600            .into(),
11601            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11602        );
11603        let mgr = SessionManager::new(SessionManagerConfig {
11604            pix_config: IncomingSessionPixConfig {
11605                quota_range: 0..=1024 * 1024 * 1024,
11606                ..Default::default()
11607            },
11608            ..Default::default()
11609        });
11610        let mut bob_transport = MockMsgSender::new();
11611        bob_transport
11612            .expect_send_message()
11613            .returning(|_, _| Box::pin(async { Ok(()) }));
11614        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11615        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11616        let _notifications = tokio::spawn(async move {
11617            pin_mut!(new_session_rx);
11618            while let Some(_session) = new_session_rx.next().await {}
11619        });
11620        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11621
11622        let alice_pseudonym = HoprPseudonym::random();
11623        mgr.handle_incoming_session_initiation(
11624            alice_pseudonym,
11625            StartInitiation {
11626                challenge: MIN_CHALLENGE,
11627                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11628                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11629                additional_data: small_pix_additional_data(),
11630            },
11631        )
11632        .await?;
11633
11634        // Grab a reference to the reconstructor before close_session consumes the slot.
11635        let pix_toolbox_ref = mgr.pix_toolbox.get().unwrap().clone();
11636        let share_processor = pix_toolbox_ref.share_processor;
11637
11638        let ssa1 = SsaId::new(alice_pseudonym, SsaIndex::MIN);
11639
11640        // Precondition: the first cycle's builder exists, so the assertion below proves actual
11641        // retirement rather than the absence of a builder that was never created. The supervisor's
11642        // opening `RequestSsa` reaches the driver asynchronously, so this waits rather than assumes.
11643        tokio::time::timeout(Duration::from_secs(1), async {
11644            while !share_processor.contains_builder(&ssa1) {
11645                tokio::time::sleep(Duration::from_millis(10)).await;
11646            }
11647        })
11648        .await
11649        .context("the opening SSA request never registered a builder")?;
11650
11651        mgr.close_session(&alice_pseudonym);
11652
11653        // Aborting the driver drops the `SsaCommitmentGuard`s it owns, which releases each SSA's
11654        // reconstructor state. The abort takes effect at the next scheduling point, hence the wait.
11655        tokio::time::timeout(Duration::from_secs(1), async {
11656            while share_processor.contains_builder(&ssa1) {
11657                tokio::time::sleep(Duration::from_millis(10)).await;
11658            }
11659        })
11660        .await
11661        .context("close_session left the in-flight SSA's reconstructor state behind")?;
11662
11663        bob_sender.close_channel();
11664        bob_handle.await??;
11665        Ok(())
11666    }
11667
11668    /// Verifies that the entry/initiator (Alice) rejects a `SsaRequest` from the exit when the
11669    /// proposed SSA quota does not match what Alice announced from her own installed generator.
11670    ///
11671    /// ## Steps
11672    /// 1. Bob's manager is started with a `PixToolbox` and a generous PIX quota config. Alice's session initiation is
11673    ///    processed with `additional_data = (polynomials=2, shares=2)`.
11674    /// 2. `handle_ssa_request` is called with a mismatched quota: `(server_polynomials=10, server_shares=10)` while
11675    ///    Alice offered `(2, 2)`.
11676    /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::Unacceptable(_))`,
11677    ///    confirming the quota mismatch was detected and rejected.
11678    #[test_log::test(tokio::test)]
11679    async fn entry_rejects_ssa_request_with_mismatched_quota() -> anyhow::Result<()> {
11680        use std::collections::BTreeMap;
11681
11682        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11683        use hopr_protocol_start::StartInitiation;
11684
11685        let ssa_gen_config = SsaGeneratorConfig {
11686            polynomials_per_ssa: 2,
11687            threshold: 2,
11688            surplus_shares: 1,
11689        };
11690
11691        let (pix_toolbox, _) = PixToolbox::new(
11692            SsaShareGenerator::new(ssa_gen_config).into(),
11693            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11694        );
11695
11696        let mgr = SessionManager::new(SessionManagerConfig {
11697            pix_config: IncomingSessionPixConfig {
11698                quota_range: 0..=1024 * 1024 * 1024,
11699                ..Default::default()
11700            },
11701            ..Default::default()
11702        });
11703
11704        let mut bob_transport = MockMsgSender::new();
11705        bob_transport
11706            .expect_send_message()
11707            .returning(|_, _| Box::pin(async { Ok(()) }));
11708        // handle_incoming_session_initiation sends SessionEstablished + SsaRequest.
11709        bob_transport
11710            .expect_send_message()
11711            .returning(|_, _| Box::pin(async { Ok(()) }));
11712
11713        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11714        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11715        let _notifications = tokio::spawn(async move {
11716            pin_mut!(new_session_rx);
11717            while let Some(_session) = new_session_rx.next().await {}
11718        });
11719        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11720
11721        let alice_pseudonym = HoprPseudonym::random();
11722
11723        mgr.handle_incoming_session_initiation(
11724            alice_pseudonym,
11725            StartInitiation {
11726                challenge: MIN_CHALLENGE,
11727                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11728                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11729                additional_data: small_pix_additional_data(),
11730            },
11731        )
11732        .await?;
11733
11734        let session_id = alice_pseudonym;
11735
11736        // Server sends dimensions of (10, 10) while we offered (2, 2) — should be rejected.
11737        let result = mgr
11738            .handle_ssa_request(
11739                alice_pseudonym,
11740                SsaServerCommitmentMessage::new(
11741                    session_id,
11742                    PixParams::try_new(10, 10, 0, LOCAL_PIX_SUITE)?,
11743                    BTreeMap::new(),
11744                    [],
11745                ),
11746            )
11747            .await;
11748
11749        bob_sender.close_channel();
11750        bob_handle.await??;
11751
11752        assert!(result.is_err());
11753        assert!(matches!(
11754            result.unwrap_err(),
11755            TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
11756        ));
11757
11758        Ok(())
11759    }
11760
11761    /// The default live-cycle budget must admit at least as many PIX Sessions as a node manages by
11762    /// default, or the Session limit stops being the first one reached and the documented default
11763    /// no longer holds.
11764    ///
11765    /// The per-Session figure is what admission reserves at the default batch of one, not the bare
11766    /// peak of one cycle: `cycle_budget_for` multiplies by `MAX_OVERLAPPING_BATCHES`.
11767    #[test]
11768    fn default_live_cycle_budget_admits_the_default_managed_session_count() {
11769        // Default of `maximum_managed_sessions` in `hopr-transport`'s `HoprProtocolConfig`, which is
11770        // not visible from this crate; keep in step with it.
11771        const DEFAULT_MANAGED_SESSIONS: u64 = 100;
11772
11773        let per_session = cycle_budget_for(&DEFAULT_PIX_PARAMS, 1);
11774        let budget = IncomingSessionPixConfig::default().max_live_cycle_bytes;
11775
11776        assert!(
11777            budget / per_session >= DEFAULT_MANAGED_SESSIONS,
11778            "a {budget}-byte budget at {per_session} bytes per Session admits {} Sessions, fewer than the \
11779             {DEFAULT_MANAGED_SESSIONS} a node manages by default",
11780            budget / per_session
11781        );
11782    }
11783
11784    /// The Entry must not admit a successor before a default Exit could possibly have produced its
11785    /// early-recovery request.
11786    ///
11787    /// Recovery is counted in completed polynomials, while emission is windowed. Every fully drained
11788    /// window before the one containing the 85% boundary has already emitted both threshold and
11789    /// surplus shares. Dividing 85% by the global surplus factor therefore underestimates the
11790    /// boundary. At the deployed split (`DEFAULT_PIX_PARAMS`, whose factor is 3.47x) the first
11791    /// possible early signal is about 83% through emission, not 24.5%; those figures belong to that
11792    /// split and move with it.
11793    ///
11794    /// The gate compares against `min_emission_for_early_recovery` directly, so this test recomputes
11795    /// the boundary independently — from the same first principles, but without calling the function
11796    /// under test — and checks the two agree. A shared helper would agree with itself.
11797    #[test]
11798    fn successor_gate_waits_until_default_early_recovery_can_be_reached() {
11799        let params = DEFAULT_PIX_PARAMS;
11800        let polys = params.polys_per_ssa() as u64;
11801        let threshold = params.shares_per_poly() as u64;
11802        let surplus = params.surplus_shares() as u64;
11803        let window = (hopr_protocol_pix::SHARE_EMISSION_WINDOW as u64).min(polys);
11804        // The floor, because that is what the gate is computed at — see
11805        // `MIN_EARLY_RECOVERY_THRESHOLD`. The default must not sit below it, or a stock Exit would
11806        // ask before a stock Entry admits.
11807        let early = hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD;
11808        assert!(
11809            SsaReconstructorConfig::default().early_recovery_threshold >= early,
11810            "the shipped default must be admissible under the protocol floor it is gated against"
11811        );
11812        let needed = (early * polys as f64).ceil() as u64;
11813
11814        // `needed` lies in this window. Earlier windows are fully exhausted, including surplus;
11815        // inside this one, `threshold - 1` complete passes and `in_window` shares of the threshold
11816        // pass are the absolute minimum that can have been emitted before those polynomials recover.
11817        let prior_windows = (needed - 1) / window;
11818        let prior_polys = prior_windows * window;
11819        let current_width = window.min(polys - prior_polys);
11820        let in_window = needed - prior_polys;
11821        let minimum_emitted = prior_polys * (threshold + surplus) + (threshold - 1) * current_width + in_window;
11822        let minimum_fraction = minimum_emitted as f64 / (polys * (threshold + surplus)) as f64;
11823
11824        let gate = hopr_protocol_pix::min_emission_for_early_recovery(&params, early);
11825        assert_eq!(
11826            minimum_emitted,
11827            gate,
11828            "the successor gate opens at {} shares ({:.1}% of emission) against an independently derived earliest \
11829             honest request of {minimum_emitted} ({:.1}%); any shortfall permits a hostile Exit to solicit a deposit \
11830             before it is earned",
11831            gate,
11832            gate as f64 / (polys * (threshold + surplus)) as f64 * 100.0,
11833            minimum_fraction * 100.0,
11834        );
11835
11836        // And the estimate this replaced is demonstrably below it, so the test would have caught it.
11837        assert!(
11838            ((early / 1.5) * (polys * (threshold + surplus)) as f64) < gate as f64,
11839            "the surplus-factor estimate must sit below the real boundary"
11840        );
11841    }
11842
11843    /// Dimensions wide enough that the protocol floor and a stricter local threshold disagree.
11844    ///
11845    /// At `small_pix_params`' two polynomials, `ceil(0.85 x 2)` and `ceil(1.0 x 2)` are both 2, so
11846    /// every threshold in range produces the same boundary and a test built on them cannot tell which
11847    /// one the gate used. Eight separates them: 7 polynomials against 8.
11848    fn wide_pix_params() -> PixParams {
11849        PixParams::try_new(8, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE).expect("test dimensions must be valid")
11850    }
11851
11852    /// Builds an Entry-side manager serving `params`, with the reconstructor at `early_threshold`.
11853    ///
11854    /// Returns the manager, its generator, and the PIX event stream, with one PIX Session already
11855    /// established for the returned pseudonym.
11856    #[allow(clippy::type_complexity)]
11857    async fn entry_with_pix_session(
11858        params: PixParams,
11859        early_threshold: f64,
11860    ) -> anyhow::Result<(
11861        SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>,
11862        Arc<SsaShareGenerator<HoprPixSpec>>,
11863        HoprPseudonym,
11864        impl futures::Stream<Item = HoprSessionOutPixEvent> + Unpin,
11865        UnboundedSender<(DestinationRouting, ApplicationDataOut)>,
11866        tokio::task::JoinHandle<anyhow::Result<()>>,
11867    )> {
11868        use hopr_protocol_start::StartInitiation;
11869
11870        let generator = Arc::new(SsaShareGenerator::new(SsaGeneratorConfig {
11871            polynomials_per_ssa: params.polys_per_ssa(),
11872            threshold: params.shares_per_poly(),
11873            surplus_shares: params.surplus_shares(),
11874        }));
11875        let (pix_toolbox, pix_events) = PixToolbox::new(
11876            generator.clone(),
11877            SsaReconstructor::new(SsaReconstructorConfig {
11878                early_recovery_threshold: early_threshold,
11879                ..Default::default()
11880            })
11881            .into(),
11882        );
11883        // This fixture's manager plays both halves, so its own Exit side opens a cycle and blocks on
11884        // the deposit pool. Answered on the way through, leaving the stream the caller reads — which
11885        // is the deposits — as it was.
11886        let pix_events = crate::testing::answering_deposit_pool(pix_events, |_| Vec::new());
11887
11888        let mgr = SessionManager::new(SessionManagerConfig {
11889            pix_config: IncomingSessionPixConfig {
11890                quota_range: 0..=1024 * 1024 * 1024,
11891                ..Default::default()
11892            },
11893            ..Default::default()
11894        });
11895
11896        let mut bob_transport = MockMsgSender::new();
11897        bob_transport
11898            .expect_send_message()
11899            .returning(|_, _| Box::pin(async { Ok(()) }));
11900        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11901        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11902        tokio::spawn(async move {
11903            pin_mut!(new_session_rx);
11904            while let Some(_session) = new_session_rx.next().await {}
11905        });
11906        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11907
11908        let pseudonym = HoprPseudonym::random();
11909        mgr.handle_incoming_session_initiation(
11910            pseudonym,
11911            StartInitiation {
11912                challenge: MIN_CHALLENGE,
11913                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11914                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11915                additional_data: into_initiation_additional_data(params.into_additional_data(0)),
11916            },
11917        )
11918        .await?;
11919
11920        Ok((mgr, generator, pseudonym, pix_events, bob_sender, bob_handle))
11921    }
11922
11923    /// One SSA commitment at `index`, from the identity element the Exit-side point is not checked
11924    /// against here.
11925    fn ssa_request_for(index: u32) -> std::collections::BTreeMap<SsaIndex, HoprPixGroupElement> {
11926        let identity = HoprPixGroupElement::try_from(
11927            hopr_protocol_pix::PixGroup::<HoprPixSpec>::default()
11928                .to_bytes()
11929                .as_ref(),
11930        )
11931        .expect("identity element must be valid");
11932        std::collections::BTreeMap::from([(SsaIndex::new(index).expect("non-zero"), identity)])
11933    }
11934
11935    /// Drains whatever the PIX event stream has ready, counting the deposits among it.
11936    ///
11937    /// Only [`ReadyToDeposit`](HoprSessionOutPixEvent::ReadyToDeposit) is counted: an Exit sharing
11938    /// this stream also asks its pool for deposit data on it, and every caller here is asking how
11939    /// many cycles were opened, not how many messages went by.
11940    async fn drain_pix_events(events: &mut (dyn futures::Stream<Item = HoprSessionOutPixEvent> + Unpin)) -> usize {
11941        let mut seen = 0;
11942        // A bounded wait rather than `now_or_never`: these fixtures answer the Exit half's
11943        // deposit-data requests on a forwarding task, so an event that has already been produced may
11944        // not have crossed it yet. The wait is only paid once, when the stream has gone quiet.
11945        while let Ok(Some(event)) = timeout(Duration::from_millis(50), events.next()).await {
11946            if matches!(event, HoprSessionOutPixEvent::ReadyToDeposit(_)) {
11947                seen += 1;
11948            }
11949        }
11950        seen
11951    }
11952
11953    /// Credits `count` Exit → Entry packets to the Session, as its receive path would.
11954    ///
11955    /// These fixtures build their slot through `handle_incoming_session_initiation`, whose receive
11956    /// path counts the *outgoing* direction, so the Entry-side counter the successor gate reads has
11957    /// to be driven by hand. `returned_packets_are_counted_on_the_entry_receive_path` is what pins
11958    /// that a real Entry Session advances it; here it only has to move.
11959    fn credit_returned_packets<S>(mgr: &SessionManager<S>, pseudonym: &HoprPseudonym, count: u64)
11960    where
11961        S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Clone + Send + Sync + Unpin + 'static,
11962        S::Error: std::error::Error + Send + Sync + 'static,
11963    {
11964        mgr.sessions
11965            .get(pseudonym)
11966            .expect("session must exist")
11967            .returned_packets
11968            .fetch_add(count, std::sync::atomic::Ordering::Relaxed);
11969    }
11970
11971    /// Packets the successor gate requires before it will admit a batch beyond `watermark`.
11972    ///
11973    /// Mirrors `handle_ssa_request`'s boundary rather than calling it, so that a change to the
11974    /// boundary has to be restated here — a helper shared with the code under test would agree with
11975    /// itself whatever either of them did. The bar set below that boundary is
11976    /// [`successor_required_packets`] itself, which
11977    /// `the_successor_bar_is_a_lower_confidence_bound_on_honest_arrivals` pins against values computed
11978    /// independently.
11979    fn required_returned_packets(params: &PixParams, watermark: u32) -> u64 {
11980        let min_emitted =
11981            hopr_protocol_pix::min_emission_for_early_recovery(params, hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD);
11982        let shares_per_cycle = params.polys_per_ssa() as u64 * params.emitted_shares_per_poly() as u64;
11983        let target = (watermark as u64 - 1) * shares_per_cycle + min_emitted;
11984        successor_required_packets(target, params.tolerated_share_loss())
11985    }
11986
11987    /// The successor gate must open at the protocol floor, not at the local reconstructor's threshold.
11988    ///
11989    /// The value that decides when a correct Exit asks for its next batch is the *peer's*
11990    /// `early_recovery_threshold`, and it does not travel on the wire. Deriving the boundary from the
11991    /// Entry's own copy meant two individually valid configurations could not communicate: an Exit
11992    /// configured lower sent its one-shot request before the gate opened, the Entry dropped it
11993    /// silently, and the Session died on `max_ssa_delivery_time` waiting for a commitment that had
11994    /// been deliberately refused — a failure visible on neither node as a configuration error.
11995    ///
11996    /// So the gate is computed at [`hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD`], which
11997    /// `validate_pix_supervision` holds every Exit to. This fixture puts the local reconstructor at
11998    /// `1.0` — the strictest end of the range — and asks at exactly the floor's boundary. A gate built
11999    /// on the local value wants one more share and refuses.
12000    #[test_log::test(tokio::test)]
12001    async fn successor_gate_opens_at_the_protocol_floor_not_the_local_threshold() -> anyhow::Result<()> {
12002        let params = wide_pix_params();
12003        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12004            entry_with_pix_session(params, 1.0).await?;
12005
12006        mgr.handle_ssa_request(
12007            pseudonym,
12008            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12009        )
12010        .await
12011        .context("the opening batch must be accepted")?;
12012        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12013
12014        // Fully served, so that the emission boundary is the only thing this test can be measuring.
12015        credit_returned_packets(&mgr, &pseudonym, required_returned_packets(&params, 1));
12016
12017        let at_floor = hopr_protocol_pix::min_emission_for_early_recovery(
12018            &params,
12019            hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD,
12020        );
12021        let at_local = hopr_protocol_pix::min_emission_for_early_recovery(&params, 1.0);
12022        assert!(
12023            at_floor < at_local,
12024            "the fixture must separate the two boundaries, got {at_floor} and {at_local}"
12025        );
12026
12027        // One share short of the floor's boundary, the request is early under either reading.
12028        for sent in 1..at_floor as u32 {
12029            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12030        }
12031        let err = mgr
12032            .handle_ssa_request(
12033                pseudonym,
12034                SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12035            )
12036            .await
12037            .expect_err("a request one share below the boundary must be refused");
12038        assert!(
12039            matches!(
12040                err,
12041                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12042            ),
12043            "expected Unacceptable, got {err:?}"
12044        );
12045        assert_eq!(0, drain_pix_events(&mut pix_events).await);
12046
12047        // On the boundary itself it is admitted — and would not be if the gate read the local 1.0.
12048        generator.next_share(&pseudonym, &at_floor.to_be_bytes())?;
12049        assert_eq!(
12050            at_floor,
12051            generator
12052                .emission_progress(&pseudonym)
12053                .expect("committed")
12054                .front_emitted,
12055            "the fixture must stand exactly on the floor's boundary"
12056        );
12057        mgr.handle_ssa_request(
12058            pseudonym,
12059            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12060        )
12061        .await
12062        .context("a request at the protocol floor must be accepted whatever the local threshold is")?;
12063        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12064
12065        bob_sender.close_channel();
12066        bob_handle.await??;
12067        Ok(())
12068    }
12069
12070    /// A successor request arriving after the generator's state was discarded must be refused.
12071    ///
12072    /// The gate only applies where there is emission progress to measure, and absent progress is
12073    /// otherwise the ordinary opening batch. But the generator keeps its per-pseudonym state in a
12074    /// cache with an idle retention refreshed by share *emission*, so a Session kept alive while the
12075    /// Entry emits nothing outlives it — and the successor gate is then not relaxed but deleted, along
12076    /// with the monotonic index the discarded entry carried. An Exit that arranges that farms one
12077    /// unearned deposit per retention period, which is the exposure the gate exists to close.
12078    ///
12079    /// `forget` reaches the same state the retention would, deliberately: an evicted entry and an
12080    /// explicitly dropped one are indistinguishable from the outside, which is precisely why the fact
12081    /// that a batch *was* committed has to be held somewhere that lives as long as the Session.
12082    ///
12083    /// The opening request in this test is the other half of the property — absent state with nothing
12084    /// committed is still admitted, or no PIX Session could ever start.
12085    #[test_log::test(tokio::test)]
12086    async fn an_ssa_request_against_discarded_generator_state_is_refused_and_closes_the_session() -> anyhow::Result<()>
12087    {
12088        let params = small_pix_params();
12089        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12090            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12091
12092        mgr.handle_ssa_request(
12093            pseudonym,
12094            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12095        )
12096        .await
12097        .context("the opening batch must be accepted")?;
12098        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12099        assert_eq!(vec![pseudonym], mgr.active_sessions());
12100
12101        generator.forget(&pseudonym);
12102        assert!(
12103            generator.emission_progress(&pseudonym).is_none(),
12104            "the fixture must reproduce the state an idle eviction leaves behind"
12105        );
12106
12107        let err = mgr
12108            .handle_ssa_request(
12109                pseudonym,
12110                SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12111            )
12112            .await
12113            .expect_err("a successor request against discarded generator state must be refused");
12114        assert!(
12115            matches!(
12116                err,
12117                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12118            ),
12119            "expected Unacceptable, got {err:?}"
12120        );
12121        assert_eq!(
12122            0,
12123            drain_pix_events(&mut pix_events).await,
12124            "a refused request must not emit a single ReadyToDeposit"
12125        );
12126        assert!(
12127            mgr.active_sessions().is_empty(),
12128            "the Entry can no longer emit shares for the cycles it committed to, so leaving the Session open only \
12129             makes the Exit wait out its commitment deadline"
12130        );
12131
12132        bob_sender.close_channel();
12133        bob_handle.await??;
12134        Ok(())
12135    }
12136
12137    /// Emission is not service: a successor must be refused until the Exit has returned data.
12138    ///
12139    /// This is H6, and it is the case the emission half of the gate cannot see. `emission_progress`
12140    /// counts shares this node handed to its own packet pipeline — a consumption `create_surb_for_path`
12141    /// does not even roll back when the rest of the packet build fails. An Exit that requests, is
12142    /// funded and then returns nothing still walks that counter forward for as long as the Entry keeps
12143    /// sending, so on its own it prices deposits against work the Entry did to itself.
12144    ///
12145    /// The fixture therefore satisfies the emission half completely and the returned half not at all.
12146    /// Before this gate existed the request below was admitted and a second deposit instruction went
12147    /// out for it.
12148    #[test_log::test(tokio::test)]
12149    async fn entry_refuses_a_successor_the_exit_has_not_paid_for_with_returned_data() -> anyhow::Result<()> {
12150        let params = wide_pix_params();
12151        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12152            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12153
12154        mgr.handle_ssa_request(
12155            pseudonym,
12156            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12157        )
12158        .await
12159        .context("the opening batch must be accepted")?;
12160        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12161
12162        // Emit the whole cycle: the emission half of the gate is now satisfied several times over,
12163        // and it is the only half that would have been consulted before.
12164        for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12165            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12166        }
12167        let progress = generator.emission_progress(&pseudonym).expect("committed");
12168        assert!(
12169            progress.is_serving_last_committed()
12170                && progress.front_emitted
12171                    >= hopr_protocol_pix::min_emission_for_early_recovery(
12172                        &params,
12173                        hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD
12174                    ),
12175            "the fixture must satisfy the emission half outright, or it is not testing the other one"
12176        );
12177
12178        let err = mgr
12179            .handle_ssa_request(
12180                pseudonym,
12181                SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12182            )
12183            .await
12184            .expect_err("a successor must not be funded before the Exit has returned anything");
12185        assert!(
12186            matches!(
12187                err,
12188                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12189            ),
12190            "expected Unacceptable, got {err:?}"
12191        );
12192        assert_eq!(
12193            0,
12194            drain_pix_events(&mut pix_events).await,
12195            "a refused successor must not emit a single ReadyToDeposit"
12196        );
12197        assert_eq!(
12198            vec![pseudonym],
12199            mgr.active_sessions(),
12200            "an under-served request is refused, not fatal — the Exit may still earn the batch it holds"
12201        );
12202
12203        // And the same request is admitted the moment the service exists.
12204        credit_returned_packets(&mgr, &pseudonym, required_returned_packets(&params, 1));
12205        mgr.handle_ssa_request(
12206            pseudonym,
12207            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12208        )
12209        .await
12210        .context("a successor must be admitted once the Exit has returned what it owes")?;
12211        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12212
12213        bob_sender.close_channel();
12214        bob_handle.await??;
12215        Ok(())
12216    }
12217
12218    /// An Exit that has returned nothing is refused immediately, not held.
12219    ///
12220    /// The deferral below the boundary exists for reordering, and it holds a bounded
12221    /// `for_each_concurrent` slot and the per-pseudonym request lock while it runs. That is only safe
12222    /// because it is entered exclusively for a *near miss*: an Exit which has returned nothing at all
12223    /// must cost nothing at all to refuse, or "ask early and say nothing" becomes a way to park one of
12224    /// this node's Start-protocol slots per Session it holds.
12225    #[test_log::test(tokio::test)]
12226    async fn an_unserved_successor_is_refused_without_waiting() -> anyhow::Result<()> {
12227        let params = wide_pix_params();
12228        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12229            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12230
12231        mgr.handle_ssa_request(
12232            pseudonym,
12233            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12234        )
12235        .await
12236        .context("the opening batch must be accepted")?;
12237        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12238
12239        for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12240            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12241        }
12242
12243        let started = std::time::Instant::now();
12244        let err = mgr
12245            .handle_ssa_request(
12246                pseudonym,
12247                SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12248            )
12249            .await
12250            .expect_err("an unserved successor must be refused");
12251        let elapsed = started.elapsed();
12252
12253        assert!(
12254            matches!(
12255                err,
12256                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12257            ),
12258            "expected Unacceptable, got {err:?}"
12259        );
12260        assert!(
12261            elapsed * 4 < SSA_SUCCESSOR_SERVICE_WAIT,
12262            "refusing an Exit that returned nothing took {elapsed:?}, which is within reach of the \
12263             {SSA_SUCCESSOR_SERVICE_WAIT:?} near-miss wait — it must not have entered it at all"
12264        );
12265
12266        bob_sender.close_channel();
12267        bob_handle.await??;
12268        Ok(())
12269    }
12270
12271    /// A request that arrives just ahead of the packets that earned it is waited for, not refused.
12272    ///
12273    /// A conforming Exit asks the instant its reconstructor crosses the threshold, and that request
12274    /// travels the same mixed path as the returned packets which unlocked the shares — so it can
12275    /// overtake the last few of them. Refusing it is not a mild outcome: `RequestSsa` is emitted once
12276    /// per index and never retried, so the Exit sits in `AwaitingCommitment` until
12277    /// `max_ssa_delivery_time` and then closes the Session as `CommitmentTimeout`. The gate must
12278    /// therefore absorb the reordering rather than treat it as under-service.
12279    #[test_log::test(tokio::test)]
12280    async fn a_near_miss_successor_waits_for_the_packets_still_in_flight() -> anyhow::Result<()> {
12281        let params = wide_pix_params();
12282        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12283            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12284
12285        mgr.handle_ssa_request(
12286            pseudonym,
12287            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12288        )
12289        .await
12290        .context("the opening batch must be accepted")?;
12291        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12292
12293        for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12294            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12295        }
12296
12297        // One packet short: the request is in flight ahead of the last one that earned it.
12298        let required = required_returned_packets(&params, 1);
12299        assert!(required > 1, "the fixture needs room to be one short");
12300        credit_returned_packets(&mgr, &pseudonym, required - 1);
12301
12302        // The straggler lands while the request is already being evaluated.
12303        let mgr_clone = mgr.clone();
12304        let late = tokio::spawn(async move {
12305            tokio::time::sleep(SSA_SUCCESSOR_SERVICE_POLL).await;
12306            credit_returned_packets(&mgr_clone, &pseudonym, 1);
12307        });
12308
12309        mgr.handle_ssa_request(
12310            pseudonym,
12311            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12312        )
12313        .await
12314        .context("a request one packet ahead of its own service must be waited for, not refused")?;
12315        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12316        late.await?;
12317
12318        bob_sender.close_channel();
12319        bob_handle.await??;
12320        Ok(())
12321    }
12322
12323    /// Service rendered before the first commitment does not pay for the first successor.
12324    ///
12325    /// Until a cycle is committed the generator holds no polynomials, so the SURBs going out carry no
12326    /// shares — and the Exit may legitimately be served up to `max_predeposit_packets` of them before
12327    /// any deposit exists. Crediting that prefix would let an Exit bank unpaid service against the
12328    /// first cycle it *is* paid for, which is the one window in a Session's life where the gate has no
12329    /// prior cycle to measure against.
12330    #[test_log::test(tokio::test)]
12331    async fn service_returned_before_the_first_commitment_is_not_credited() -> anyhow::Result<()> {
12332        let params = wide_pix_params();
12333        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12334            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12335
12336        // Generously served *before* anything is committed, and none of it may count.
12337        let required = required_returned_packets(&params, 1);
12338        credit_returned_packets(&mgr, &pseudonym, required * 10);
12339
12340        mgr.handle_ssa_request(
12341            pseudonym,
12342            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12343        )
12344        .await
12345        .context("the opening batch must be accepted")?;
12346        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12347
12348        for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12349            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12350        }
12351
12352        let err = mgr
12353            .handle_ssa_request(
12354                pseudonym,
12355                SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12356            )
12357            .await
12358            .expect_err("pre-commitment service must not pay for the first successor");
12359        assert!(
12360            matches!(
12361                err,
12362                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12363            ),
12364            "expected Unacceptable, got {err:?}"
12365        );
12366        assert_eq!(0, drain_pix_events(&mut pix_events).await);
12367
12368        bob_sender.close_channel();
12369        bob_handle.await??;
12370        Ok(())
12371    }
12372
12373    /// The boundary is discounted by the loss a cycle tolerates, not by the surplus ratio.
12374    ///
12375    /// The Exit unlocks a share when the *first relayer* acknowledges its returned packet, which is
12376    /// upstream of the Entry — so everything lost after that point is progress the Exit legitimately
12377    /// has and this node cannot observe, and demanding the undiscounted emission boundary would refuse
12378    /// conforming Exits on any lossy path. But the discount is also what an Exit can withhold for good
12379    /// and still be funded, so it may be no larger than the loss a cycle survives.
12380    ///
12381    /// The surplus ratio `s/(t+s)` is break-even, not survival. At the deployed split it discounted
12382    /// 71.2 % where a cycle tolerates 50.3 %, and put the bar at 23.9 % of a cycle — below the shares a
12383    /// lossless Exit has necessarily unlocked by the earliest point it may ask at all.
12384    ///
12385    /// The bar is then set [`SUCCESSOR_GATE_ARRIVAL_SIGMAS`] standard deviations below the mean an
12386    /// Exit at that loss returns, so that one losing exactly the tolerance is not refused for the
12387    /// variance of its own arrivals.
12388    ///
12389    /// Pinned at the deployed dimensions because the resulting fraction is not something a reader can
12390    /// check by inspection, and it is what decides when real money moves.
12391    #[test]
12392    fn the_successor_boundary_is_discounted_by_the_loss_a_cycle_tolerates() -> anyhow::Result<()> {
12393        use crate::{DEFAULT_PIX_POLYS_PER_SSA, DEFAULT_PIX_SHARES_PER_POLY, DEFAULT_PIX_SURPLUS_SHARES};
12394
12395        let params = PixParams::try_new(
12396            DEFAULT_PIX_POLYS_PER_SSA,
12397            DEFAULT_PIX_SHARES_PER_POLY,
12398            DEFAULT_PIX_SURPLUS_SHARES,
12399            LOCAL_PIX_SUITE,
12400        )?;
12401        let cycle = params.polys_per_ssa() as u64 * params.emitted_shares_per_poly() as u64;
12402        assert_eq!(454_656, cycle);
12403
12404        let undiscounted = hopr_protocol_pix::min_emission_for_early_recovery(
12405            &params,
12406            hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD,
12407        );
12408        assert_eq!(377_498, undiscounted);
12409
12410        // floor(μ − 4σ) for μ = 377 498 × (1 − L) = 187 660.58 and σ = √(377 498 · L · (1 − L)) =
12411        // 307.20, at L = 0.502 883 257 4… computed independently at 60 significant digits.
12412        let required = required_returned_packets(&params, 1);
12413        assert_eq!(186_431, required);
12414
12415        let tolerated = params.tolerated_share_loss();
12416        let mean = undiscounted as f64 * (1.0 - tolerated);
12417        let deviation = (undiscounted as f64 * tolerated * (1.0 - tolerated)).sqrt();
12418        let bound = mean - SUCCESSOR_GATE_ARRIVAL_SIGMAS * deviation;
12419        assert!(
12420            required as f64 <= bound && bound < required as f64 + 1.0,
12421            "the bar must be the mean arrivals at the tolerated loss less {SUCCESSOR_GATE_ARRIVAL_SIGMAS} of their \
12422             standard deviations, floored, and nothing else: {bound} against {required}"
12423        );
12424
12425        let fraction = required as f64 / cycle as f64;
12426        assert!(
12427            (0.409..0.411).contains(&fraction),
12428            "the deployed successor boundary must sit at ~41.0% of a cycle's returned packets, got {fraction}"
12429        );
12430
12431        // A lossless conforming Exit asks no earlier than `ceil(0.85 × 4096)` recovered polynomials,
12432        // each of which took `t` unlocked shares before any of its surplus. A bar below that funds the
12433        // next deposit on less delivered service than the useful shares the Exit is claiming to have
12434        // recovered from.
12435        let unlocked_by_a_lossless_exit =
12436            (hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD * DEFAULT_PIX_POLYS_PER_SSA as f64).ceil() as u64
12437                * DEFAULT_PIX_SHARES_PER_POLY as u64;
12438        assert_eq!(111_424, unlocked_by_a_lossless_exit);
12439        assert!(
12440            required >= unlocked_by_a_lossless_exit,
12441            "the successor boundary ({required}) must not sit below what a lossless Exit has unlocked when it may \
12442             first ask ({unlocked_by_a_lossless_exit})"
12443        );
12444
12445        // Each further cycle of a batch is demanded in full at the same discount, less only what the
12446        // margin grows by — and that grows with the square root of the target, so by less than the
12447        // margin a cycle would get on its own. The floor can move either end by one packet.
12448        let whole_first_cycle = required_returned_packets(&params, 2) - required;
12449        let discounted_cycle = cycle as f64 * (1.0 - tolerated);
12450        let lone_cycle_margin = SUCCESSOR_GATE_ARRIVAL_SIGMAS * (cycle as f64 * tolerated * (1.0 - tolerated)).sqrt();
12451        assert!(
12452            (discounted_cycle - lone_cycle_margin - 1.0..=discounted_cycle + 1.0).contains(&(whole_first_cycle as f64)),
12453            "a batch of two must demand the first cycle's whole discounted worth, less under {lone_cycle_margin} of \
12454             margin, plus the second's boundary; got {whole_first_cycle} for the first cycle against \
12455             {discounted_cycle}"
12456        );
12457
12458        Ok(())
12459    }
12460
12461    /// The successor gate follows the surplus the Entry bought.
12462    ///
12463    /// The discount is the tolerance of the *negotiated* split, so an Entry that expects unreliable
12464    /// return relayers and buys more surplus loosens its own gate by exactly that much: how strict the
12465    /// gate is follows from the Entry's insurance choice rather than being a knob of its own. At
12466    /// `4096 × (2 + 102)` a cycle tolerates ~86 % share loss, and the bar falls to ~11.3 % of a cycle's
12467    /// packets against the deployed split's ~41.0 %.
12468    ///
12469    /// Driven through `handle_ssa_request` at the real dimensions rather than only through
12470    /// [`required_returned_packets`], because the property is that the gate reads the Session's own
12471    /// parameters — a mirror of its arithmetic cannot show that.
12472    #[test_log::test(tokio::test)]
12473    async fn the_successor_gate_follows_the_surplus_the_entry_bought() -> anyhow::Result<()> {
12474        let params = PixParams::try_new(4096, 2, 102, LOCAL_PIX_SUITE)?;
12475        let cycle = params.polys_per_ssa() as u64 * params.emitted_shares_per_poly() as u64;
12476        let undiscounted = hopr_protocol_pix::min_emission_for_early_recovery(
12477            &params,
12478            hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD,
12479        );
12480
12481        let required = required_returned_packets(&params, 1);
12482        // floor(μ − 4σ) for μ = 346 522 × (1 − L) = 48 876.87 and σ = 204.90, at L = 0.858 950 169 2…
12483        // computed independently at 80 significant digits.
12484        assert_eq!(346_522, undiscounted);
12485        assert_eq!(48_057, required);
12486
12487        let fraction = required as f64 / cycle as f64;
12488        assert!(
12489            (0.112..0.114).contains(&fraction),
12490            "the successor boundary of 4096 x (2 + 102) must sit at ~11.3% of a cycle's returned packets, got \
12491             {fraction}"
12492        );
12493
12494        // And the gate itself, not only its mirror: with emission past the boundary, one emission
12495        // window short of `required` is refused outright — past the near-miss wait, so at once —
12496        // and the rest of it admits the successor.
12497        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12498            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12499        mgr.handle_ssa_request(
12500            pseudonym,
12501            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12502        )
12503        .await
12504        .context("the opening batch must be accepted")?;
12505        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12506
12507        for sent in 1..=undiscounted as u32 {
12508            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12509        }
12510
12511        let short = hopr_protocol_pix::SHARE_EMISSION_WINDOW as u64 + 1;
12512        credit_returned_packets(&mgr, &pseudonym, required - short);
12513        let err = mgr
12514            .handle_ssa_request(
12515                pseudonym,
12516                SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12517            )
12518            .await
12519            .expect_err("a successor short of the bought tolerance's boundary must be refused");
12520        assert!(
12521            matches!(
12522                err,
12523                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12524            ),
12525            "expected Unacceptable, got {err:?}"
12526        );
12527        assert_eq!(0, drain_pix_events(&mut pix_events).await);
12528
12529        credit_returned_packets(&mgr, &pseudonym, short);
12530        mgr.handle_ssa_request(
12531            pseudonym,
12532            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12533        )
12534        .await
12535        .context("a successor at the bought tolerance's boundary must be admitted")?;
12536        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12537
12538        bob_sender.close_channel();
12539        bob_handle.await??;
12540        Ok(())
12541    }
12542
12543    /// A tiny boundary under a huge surplus must still demand one returned packet.
12544    ///
12545    /// At `128 × (2 + 255)` the early-recovery boundary is 237 packets against a tolerated loss of
12546    /// ~95 %: μ − 4σ is below one, and an unfloored bar of zero funds the successor on the Entry's
12547    /// own emission with nothing returned — the near-miss wait is skipped on zero observed packets, so
12548    /// nothing else would stop it.
12549    #[test_log::test(tokio::test)]
12550    async fn the_successor_gate_demands_a_returned_packet_whatever_the_surplus() -> anyhow::Result<()> {
12551        let params = PixParams::try_new(128, 2, 255, LOCAL_PIX_SUITE)?;
12552        let target = hopr_protocol_pix::min_emission_for_early_recovery(
12553            &params,
12554            hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD,
12555        );
12556        assert_eq!(237, target);
12557
12558        let tolerated = params.tolerated_share_loss();
12559        let mean = target as f64 * (1.0 - tolerated);
12560        let deviation = (target as f64 * tolerated * (1.0 - tolerated)).sqrt();
12561        let raw_bound = mean - SUCCESSOR_GATE_ARRIVAL_SIGMAS * deviation;
12562        assert!(
12563            raw_bound < 1.0,
12564            "the unfloored bound must be below one packet, got {raw_bound}"
12565        );
12566        assert_eq!(1, required_returned_packets(&params, 1));
12567
12568        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12569            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12570        mgr.handle_ssa_request(
12571            pseudonym,
12572            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12573        )
12574        .await
12575        .context("the opening batch must be accepted")?;
12576        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12577
12578        for sent in 1..=target as u32 {
12579            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12580        }
12581
12582        let err = mgr
12583            .handle_ssa_request(
12584                pseudonym,
12585                SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12586            )
12587            .await
12588            .expect_err("a successor with no returned packet must be refused");
12589        assert!(
12590            matches!(
12591                err,
12592                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12593            ),
12594            "expected Unacceptable, got {err:?}"
12595        );
12596        assert_eq!(0, drain_pix_events(&mut pix_events).await);
12597
12598        credit_returned_packets(&mgr, &pseudonym, 1);
12599        mgr.handle_ssa_request(
12600            pseudonym,
12601            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12602        )
12603        .await
12604        .context("a successor with one returned packet must be admitted")?;
12605        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12606
12607        bob_sender.close_channel();
12608        bob_handle.await??;
12609        Ok(())
12610    }
12611
12612    /// The bar is a lower confidence bound on an honest Exit's arrivals, not their mean.
12613    ///
12614    /// Pinned against `floor(μ − 4σ)` computed outside this crate from independently computed
12615    /// tolerances: at the deployed split, at one an Entry distrusting its return relayers may buy, and
12616    /// at the derived `2048 × (64 + 123)`, where the mean refused an honest Exit. The ends are what tie
12617    /// the bound to the split — no surplus, no tolerance and so no slack; a tolerance near one asks
12618    /// for one packet rather than for a negative count or nothing.
12619    #[test]
12620    fn the_successor_bar_is_a_lower_confidence_bound_on_honest_arrivals() -> anyhow::Result<()> {
12621        for target in [0, 1, 377_498, 1 << 40] {
12622            assert_eq!(
12623                target,
12624                successor_required_packets(target, 0.0),
12625                "a split tolerating no loss must be demanded its whole target"
12626            );
12627        }
12628
12629        // 100 × 0.01 − 4 × √(100 × 0.01 × 0.99) < 0: the margin outweighs the mean.
12630        // ... but never below one packet of a non-zero boundary, and a zero boundary stays zero.
12631        assert_eq!(1, successor_required_packets(100, 0.99));
12632        assert_eq!(1, successor_required_packets(377_498, 1.0));
12633        for loss in [0.0, 0.5, 0.99, 1.0] {
12634            assert_eq!(
12635                0,
12636                successor_required_packets(0, loss),
12637                "a zero boundary demands nothing at L = {loss}"
12638            );
12639        }
12640
12641        for (polys, threshold, surplus, target, required) in [
12642            (4096, 32, 79, 377_498, 186_431),
12643            (4096, 2, 102, 346_522, 48_057),
12644            (2048, 64, 123, 303_565, 150_641),
12645        ] {
12646            let params = PixParams::try_new(polys, threshold, surplus, LOCAL_PIX_SUITE)?;
12647            assert_eq!(
12648                required,
12649                successor_required_packets(target, params.tolerated_share_loss()),
12650                "{polys} x ({threshold} + {surplus}) must demand {required} of a {target}-packet boundary"
12651            );
12652        }
12653
12654        Ok(())
12655    }
12656
12657    /// An honest Exit losing the tolerated share fraction must not lose its Session to the variance
12658    /// of its own arrivals.
12659    ///
12660    /// Losses past the first return relayer cost the Exit nothing — the relayer has acknowledged, so
12661    /// the share is unlocked — and it asks at the lossless boundary with `Binomial(target, 1 − L)` of
12662    /// its packets having reached the Entry. A bar at their mean refused it about half the time, and
12663    /// at this split ~18 % of the time by more than the near-miss window, at once; `RequestSsa` is
12664    /// never retried, so the Session then died on `max_ssa_delivery_time`.
12665    ///
12666    /// The draw is the one an independent review reproduced that with, at the surplus derived for
12667    /// `2048 × 64`: 151 352 of 303 565 packets arrived, 391 short of the mean.
12668    #[test_log::test(tokio::test)]
12669    async fn an_honest_exit_at_the_tolerated_loss_is_not_refused_for_its_variance() -> anyhow::Result<()> {
12670        use hopr_protocol_pix::{MIN_EARLY_RECOVERY_THRESHOLD, SHARE_EMISSION_WINDOW, default_surplus_for};
12671
12672        // 303 565 independent fair bits (Python `random.Random(3)`).
12673        const ARRIVED: u64 = 151_352;
12674
12675        let params = PixParams::try_new(2048, 64, default_surplus_for(2048, 64), LOCAL_PIX_SUITE)?;
12676        assert_eq!(123, params.surplus_shares());
12677        assert!(
12678            IncomingSessionPixConfig::default()
12679                .quota_range
12680                .contains(&pix_params_to_quota(&params)),
12681            "the split must be one a default Exit accepts"
12682        );
12683
12684        let boundary = hopr_protocol_pix::min_emission_for_early_recovery(&params, MIN_EARLY_RECOVERY_THRESHOLD);
12685        assert_eq!(303_565, boundary);
12686
12687        // floor(μ − 4σ) for μ = 303 565 × (1 − L) = 151 743.28 and σ = 275.48, at
12688        // L = 0.500 129 201 488 385 6.
12689        let required = required_returned_packets(&params, 1);
12690        assert_eq!(150_641, required);
12691
12692        // What this pins: a bar at the mean would have refused the draw outright, never reaching the
12693        // near-miss wait, where the confidence bound admits it.
12694        let at_the_mean = (boundary as f64 * (1.0 - params.tolerated_share_loss())) as u64;
12695        assert_eq!(151_743, at_the_mean);
12696        assert!(
12697            ARRIVED + (SHARE_EMISSION_WINDOW as u64) < at_the_mean,
12698            "the draw must fall more than one emission window short of the mean, or a mean bar would have waited"
12699        );
12700        assert!(ARRIVED >= required, "the draw must clear the confidence bound");
12701
12702        let (mgr, generator, pseudonym, mut events, sender, handle) =
12703            entry_with_pix_session(params, MIN_EARLY_RECOVERY_THRESHOLD).await?;
12704        mgr.handle_ssa_request(
12705            pseudonym,
12706            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12707        )
12708        .await?;
12709        assert_eq!(1, drain_pix_events(&mut events).await);
12710
12711        for n in 1..=boundary as u32 {
12712            generator.next_share(&pseudonym, &n.to_be_bytes())?;
12713        }
12714
12715        // Every packet was acknowledged by the first relayer; the losses are downstream, so the Exit
12716        // asks at the boundary.
12717        credit_returned_packets(&mgr, &pseudonym, ARRIVED);
12718        let result = mgr
12719            .handle_ssa_request(
12720                pseudonym,
12721                SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12722            )
12723            .await;
12724
12725        sender.close_channel();
12726        handle.await??;
12727        result.context("honest Exit on a 50 % downstream-loss path lost its only successor request")?;
12728        assert_eq!(1, drain_pix_events(&mut events).await);
12729
12730        Ok(())
12731    }
12732
12733    /// A real Entry Session must advance the counter the successor gate reads.
12734    ///
12735    /// The gate's worst failure is not being too strict, it is being wired to a counter nothing
12736    /// feeds: that closes it permanently, and every PIX Session then dies on its second cycle with
12737    /// the Exit blaming its own commitment deadline. The other tests here drive
12738    /// `returned_packets` by hand, so none of them would notice.
12739    ///
12740    /// Deliberately built with `surb_management: None`. That is the branch which passed `session_rx`
12741    /// through untouched and left `surb_estimator` at its default — the one place where reusing the
12742    /// balancer's estimate instead of a counter of our own would have produced exactly that silent
12743    /// permanent closure.
12744    #[test_log::test(tokio::test)]
12745    async fn returned_packets_are_counted_on_the_entry_receive_path() -> anyhow::Result<()> {
12746        use hopr_protocol_start::StartProtocolDiscriminants;
12747
12748        let alice_pseudonym = HoprPseudonym::random();
12749        let bob_peer: Address = (&ChainKeypair::random()).into();
12750
12751        let alice_mgr = SessionManager::new(Default::default());
12752        let bob_mgr = SessionManager::new(Default::default());
12753
12754        let mut alice_transport = MockMsgSender::new();
12755        let mut bob_transport = MockMsgSender::new();
12756
12757        // Alice → Bob, and Bob → Alice, with no filtering: the handshake is not what is under test.
12758        let bob_mgr_clone = bob_mgr.clone();
12759        alice_transport.expect_send_message().returning(move |_, data| {
12760            let bob_mgr_clone = bob_mgr_clone.clone();
12761            Box::pin(async move {
12762                let _ = bob_mgr_clone.dispatch_message(
12763                    alice_pseudonym,
12764                    ApplicationDataIn {
12765                        data: data.data,
12766                        packet_info: Default::default(),
12767                    },
12768                );
12769                Ok(())
12770            })
12771        });
12772        let alice_mgr_clone = alice_mgr.clone();
12773        bob_transport
12774            .expect_send_message()
12775            .withf(|_, data| msg_type(data, StartProtocolDiscriminants::SessionEstablished))
12776            .returning(move |_, data| {
12777                let alice_mgr_clone = alice_mgr_clone.clone();
12778                Box::pin(async move {
12779                    alice_mgr_clone.dispatch_message(
12780                        alice_pseudonym,
12781                        ApplicationDataIn {
12782                            data: data.data,
12783                            packet_info: Default::default(),
12784                        },
12785                    )?;
12786                    Ok(())
12787                })
12788            });
12789
12790        let (alice_sender, alice_handle) = mock_packet_planning(alice_transport);
12791        let (new_session_tx_alice, _alice_rx) = futures::channel::mpsc::channel(1024);
12792        alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?;
12793
12794        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
12795        let (new_session_tx_bob, new_session_rx_bob) = futures::channel::mpsc::channel(1024);
12796        bob_mgr.start(bob_sender.clone(), new_session_tx_bob, None, None)?;
12797        let _bob_notifications = tokio::spawn(async move {
12798            pin_mut!(new_session_rx_bob);
12799            while let Some(_session) = new_session_rx_bob.next().await {}
12800        });
12801
12802        let alice_session = alice_mgr
12803            .new_session(
12804                bob_peer,
12805                SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
12806                SessionClientConfig {
12807                    pseudonym: alice_pseudonym.into(),
12808                    capabilities: None.into(),
12809                    surb_management: None,
12810                    ..Default::default()
12811                },
12812            )
12813            .await
12814            .context("the session must establish")?;
12815
12816        let counter = alice_mgr
12817            .sessions
12818            .get(&alice_pseudonym)
12819            .expect("alice must hold the session slot")
12820            .returned_packets;
12821        assert_eq!(
12822            0,
12823            counter.load(std::sync::atomic::Ordering::Relaxed),
12824            "nothing has come back yet"
12825        );
12826
12827        // Three packets arriving the way the Exit's return traffic does.
12828        for i in 0..3u8 {
12829            alice_mgr.dispatch_message(
12830                alice_pseudonym,
12831                ApplicationDataIn {
12832                    data: ApplicationData::new(SESSION_APPLICATION_TAG, &[i])?,
12833                    packet_info: Default::default(),
12834                },
12835            )?;
12836        }
12837
12838        // The count is taken as the Session drains its receiver, and the receiver is only polled by a
12839        // reader — which is exactly how it behaves in production, where the client is reading.
12840        let mut alice_session = alice_session;
12841        let mut buffer = [0u8; 16];
12842        for _ in 0..50 {
12843            if counter.load(std::sync::atomic::Ordering::Relaxed) >= 3 {
12844                break;
12845            }
12846            let _ = tokio::time::timeout(
12847                Duration::from_millis(20),
12848                futures::AsyncReadExt::read(&mut alice_session, &mut buffer),
12849            )
12850            .await;
12851        }
12852        assert_eq!(
12853            3,
12854            counter.load(std::sync::atomic::Ordering::Relaxed),
12855            "every Exit → Entry packet must be counted, or the successor gate can never open"
12856        );
12857
12858        drop(alice_session);
12859        alice_sender.close_channel();
12860        bob_sender.close_channel();
12861        let _ = alice_handle.await?;
12862        let _ = bob_handle.await?;
12863        Ok(())
12864    }
12865
12866    /// Re-points the fixture's slot at `Forward` routing, the way a real Entry's slot is built.
12867    ///
12868    /// [`entry_with_pix_session`] establishes through `handle_incoming_session_initiation` because
12869    /// that is the path which installs the PIX state the successor gate needs, and it stores the
12870    /// Exit's `Return` reply routing. The gate is an Entry-side rule, and the counter it reads is
12871    /// only credited on Sessions this node *initiated* — Start-protocol traffic on an incoming
12872    /// Session comes from the Entry over the forward path and must not be paid for. So the routing is
12873    /// what decides whether the keep-alives below are read as service arriving or as service being
12874    /// asked for, and the fixture has to be put on the side the rule is about.
12875    ///
12876    /// Re-inserting rather than mutating: the slot lives in a `moka` cache by value, and its routing
12877    /// is a plain field rather than a cell, because nothing in production ever changes it.
12878    fn make_session_outgoing<S>(mgr: &SessionManager<S>, pseudonym: &HoprPseudonym, destination: Address)
12879    where
12880        S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Clone + Send + Sync + Unpin + 'static,
12881        S::Error: std::error::Error + Send + Sync + 'static,
12882    {
12883        let mut slot = mgr.sessions.get(pseudonym).expect("session must exist");
12884        slot.routing_opts = DestinationRouting::Forward {
12885            destination: Box::new(destination.into()),
12886            pseudonym: Some(*pseudonym),
12887            forward_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN),
12888            return_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN).into(),
12889        };
12890        mgr.sessions.insert(*pseudonym, slot);
12891    }
12892
12893    /// A cycle served only by the Exit's keep-alives must still buy its successor.
12894    ///
12895    /// This is the property that lets the Exit finish a funded cycle whose application has fallen
12896    /// silent: it sends its own Exit → Entry keep-alives, each one consuming a return SURB and
12897    /// therefore unlocking one share, and the cycle completes on those alone. If they earned nothing
12898    /// at this gate the Session would die at exactly the moment it succeeded — the Exit's `RequestSsa`
12899    /// for the successor is emitted once and never retried, so a refusal here leaves it in
12900    /// `AwaitingCommitment` until `max_ssa_delivery_time` and then closes the Session as
12901    /// `CommitmentTimeout`, naming a timer rather than the rule that fired.
12902    ///
12903    /// Driven through [`dispatch_message`](SessionManager::dispatch_message) with real encoded
12904    /// keep-alives rather than by touching the counter, because the credit lives on that path and
12905    /// nowhere else: the message never reaches `session_rx`, so the receive-path inspector that
12906    /// [`returned_packets_are_counted_on_the_entry_receive_path`] pins cannot cover for it.
12907    #[test_log::test(tokio::test)]
12908    async fn keep_alives_alone_can_carry_a_cycle_to_its_successor() -> anyhow::Result<()> {
12909        let params = wide_pix_params();
12910        let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12911            entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12912
12913        mgr.handle_ssa_request(
12914            pseudonym,
12915            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12916        )
12917        .await
12918        .context("the opening batch must be accepted")?;
12919        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12920
12921        // Emission reaches the boundary, so service is the only thing left for the gate to weigh.
12922        for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12923            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12924        }
12925
12926        make_session_outgoing(&mgr, &pseudonym, (&ChainKeypair::random()).into());
12927
12928        let counter = mgr
12929            .sessions
12930            .get(&pseudonym)
12931            .expect("session must exist")
12932            .returned_packets;
12933        assert_eq!(
12934            0,
12935            counter.load(std::sync::atomic::Ordering::Relaxed),
12936            "the fixture must start with nothing served"
12937        );
12938
12939        // Exactly what the gate demands, and every packet of it a keep-alive.
12940        let required = required_returned_packets(&params, 1);
12941        for level in 0..required {
12942            let keep_alive: ApplicationData = HoprStartProtocol::KeepAlive(KeepAliveMessage {
12943                session_id: pseudonym,
12944                flags: KeepAliveFlag::BalancerState.into(),
12945                additional_data: level,
12946            })
12947            .try_into()?;
12948            mgr.dispatch_message(
12949                pseudonym,
12950                ApplicationDataIn {
12951                    data: keep_alive,
12952                    packet_info: Default::default(),
12953                },
12954            )?;
12955        }
12956        assert_eq!(
12957            required,
12958            counter.load(std::sync::atomic::Ordering::Relaxed),
12959            "every keep-alive consumed a return SURB and must be credited as service"
12960        );
12961
12962        mgr.handle_ssa_request(
12963            pseudonym,
12964            SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12965        )
12966        .await
12967        .context("a cycle served entirely by keep-alives must still buy its successor")?;
12968        assert_eq!(1, drain_pix_events(&mut pix_events).await);
12969
12970        bob_sender.close_channel();
12971        bob_handle.await??;
12972        Ok(())
12973    }
12974
12975    /// The Entry's half of the successor gate: a batch asked for before emission has reached the last
12976    /// cycle of the batch already committed must be refused, committing nothing and depositing
12977    /// nothing — and the Session must survive it.
12978    ///
12979    /// The Exit-side gate in `SessionPixSupervisor` means a correct peer never lands here. This is
12980    /// what makes a peer that *does* — an unpatched or hostile Exit answering every cycle's
12981    /// early-recovery signal with a fresh batch — cost the Entry nothing rather than one on-chain
12982    /// deposit per surplus request.
12983    ///
12984    /// Ordering across a batch is pinned where it lives, on the generator, by
12985    /// `emission_never_crosses_a_cycle_boundary_early` and
12986    /// `emission_progress_lags_the_commitment_index_across_a_batch`. What this test drives is the
12987    /// wiring and all three points of the gate: nothing emitted at all, emission having merely
12988    /// *reached* the last committed cycle, and emission far enough into it.
12989    ///
12990    /// The middle point is the regression. Admission used to be "emission has reached the last
12991    /// committed index", which becomes true on that cycle's very first share — so a successor batch
12992    /// was admitted ~0 % into the batch rather than the ~85 % at which a conforming Exit asks. See
12993    /// [`MIN_SUCCESSOR_EMISSION_FRACTION`].
12994    #[test_log::test(tokio::test)]
12995    async fn entry_refuses_a_batch_asked_for_before_emission_reaches_the_current_one() -> anyhow::Result<()> {
12996        use std::collections::BTreeMap;
12997
12998        use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
12999        use hopr_protocol_start::StartInitiation;
13000
13001        const BATCH: u32 = 3;
13002
13003        let generator = Arc::new(SsaShareGenerator::new(SsaGeneratorConfig {
13004            polynomials_per_ssa: 2,
13005            threshold: 2,
13006            surplus_shares: 1,
13007        }));
13008        let (pix_toolbox, pix_events) = PixToolbox::new(
13009            generator.clone(),
13010            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13011        );
13012        // This manager plays both halves, so its own Exit side opens a cycle and blocks on the
13013        // deposit pool. Answered on the way through; the deposits this test counts pass on unchanged.
13014        let mut pix_events = crate::testing::answering_deposit_pool(pix_events, |_| Vec::new());
13015
13016        let mgr = SessionManager::new(SessionManagerConfig {
13017            max_ssas_per_ssa_request: BATCH as usize,
13018            pix_config: IncomingSessionPixConfig {
13019                quota_range: 0..=1024 * 1024 * 1024,
13020                ..Default::default()
13021            },
13022            ..Default::default()
13023        });
13024
13025        let mut bob_transport = MockMsgSender::new();
13026        bob_transport
13027            .expect_send_message()
13028            .returning(|_, _| Box::pin(async { Ok(()) }));
13029
13030        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13031        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13032        let _notifications = tokio::spawn(async move {
13033            pin_mut!(new_session_rx);
13034            while let Some(_session) = new_session_rx.next().await {}
13035        });
13036        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
13037
13038        let pseudonym = HoprPseudonym::random();
13039        mgr.handle_incoming_session_initiation(
13040            pseudonym,
13041            StartInitiation {
13042                challenge: MIN_CHALLENGE,
13043                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13044                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13045                additional_data: small_pix_additional_data(),
13046            },
13047        )
13048        .await?;
13049
13050        let identity = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
13051            .expect("identity element must be valid");
13052        let batch = |from: u32| {
13053            (from..from + BATCH)
13054                .map(|i| (SsaIndex::new(i).expect("non-zero"), identity))
13055                .collect::<BTreeMap<_, _>>()
13056        };
13057
13058        // The opening batch is accepted: nothing is committed yet, so there is nothing to be early for.
13059        mgr.handle_ssa_request(
13060            pseudonym,
13061            SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(1), []),
13062        )
13063        .await
13064        .context("the opening batch must be accepted")?;
13065        assert_eq!(
13066            drain_pix_events(&mut pix_events).await,
13067            BATCH as usize,
13068            "the opening batch must yield one deposit address per SSA"
13069        );
13070
13071        // Fully served for the batch, so emission ordering is the only thing under test here. The
13072        // returned-data half of the gate has its own tests.
13073        credit_returned_packets(&mgr, &pseudonym, required_returned_packets(&small_pix_params(), BATCH));
13074
13075        // Asking again before a single share has gone out is a whole batch early.
13076        let err = mgr
13077            .handle_ssa_request(
13078                pseudonym,
13079                SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(BATCH + 1), []),
13080            )
13081            .await
13082            .expect_err("a batch asked for before any share was emitted must be refused");
13083        assert!(
13084            matches!(
13085                err,
13086                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
13087            ),
13088            "expected Unacceptable, got {err:?}"
13089        );
13090        assert_eq!(
13091            drain_pix_events(&mut pix_events).await,
13092            0,
13093            "a refused batch must not emit a single ReadyToDeposit"
13094        );
13095        assert_eq!(
13096            mgr.active_sessions(),
13097            vec![pseudonym],
13098            "refusing a request must not close the Session"
13099        );
13100
13101        // Emit until the last cycle of the batch has taken the front — but no further.
13102        let mut sent = 0u32;
13103        let mut emit_until = |pred: &dyn Fn(&hopr_protocol_pix::EmissionProgress) -> bool| -> anyhow::Result<()> {
13104            for _ in 0..1024 {
13105                if generator.emission_progress(&pseudonym).as_ref().is_some_and(pred) {
13106                    return Ok(());
13107                }
13108                sent += 1;
13109                generator.next_share(&pseudonym, &sent.to_be_bytes())?;
13110            }
13111            anyhow::bail!("emission did not reach the expected point")
13112        };
13113        emit_until(&|p| p.is_serving_last_committed())?;
13114
13115        // The boundary the gate actually uses, recomputed from the same negotiated params.
13116        let min_emitted = hopr_protocol_pix::min_emission_for_early_recovery(
13117            &small_pix_params(),
13118            SsaReconstructorConfig::default().early_recovery_threshold,
13119        );
13120
13121        // Reaching the last cycle is *not* enough, and this is the H2 regression. The index-only gate
13122        // this replaced admitted a successor batch right here — on the last cycle's first share, ~0 %
13123        // of the way through the batch — which is nearly a whole cycle of deposits the Exit has not
13124        // earned, on the one gate whose purpose is to prevent exactly that.
13125        let progress = generator.emission_progress(&pseudonym).expect("committed");
13126        assert!(
13127            progress.front_emitted < min_emitted,
13128            "the test must stand at the start of the last cycle, got {} of {min_emitted}",
13129            progress.front_emitted
13130        );
13131        let err = mgr
13132            .handle_ssa_request(
13133                pseudonym,
13134                SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(BATCH + 1), []),
13135            )
13136            .await
13137            .expect_err("reaching the last cycle must not by itself admit the next batch");
13138        assert!(
13139            matches!(
13140                err,
13141                TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
13142            ),
13143            "expected Unacceptable, got {err:?}"
13144        );
13145        assert_eq!(
13146            drain_pix_events(&mut pix_events).await,
13147            0,
13148            "a batch refused for being early must not emit a single ReadyToDeposit"
13149        );
13150
13151        // Far enough into that last cycle, the same batch is admitted.
13152        emit_until(&|p| p.front_emitted >= min_emitted)?;
13153        mgr.handle_ssa_request(
13154            pseudonym,
13155            SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(BATCH + 1), []),
13156        )
13157        .await
13158        .context("a batch asked for on time must be accepted")?;
13159
13160        bob_sender.close_channel();
13161        bob_handle.await??;
13162        Ok(())
13163    }
13164
13165    /// Concurrent `SsaRequest`s for one pseudonym must admit exactly one batch.
13166    ///
13167    /// Start messages are processed under `for_each_concurrent`, so several requests for the same
13168    /// Session run at once, and each reads the successor gate before any of them has advanced it.
13169    /// Requests are issued at distinct, ascending index ranges because that is the shape the
13170    /// generator's monotonic index cannot refuse — it rejects equal or lower indices, and an Exit
13171    /// numbering its batches upwards never offers one.
13172    ///
13173    /// **What this pins is the invariant, not the mechanism.** At this fixture's dimensions — two
13174    /// polynomials at threshold two — `new_ssa_commitment` returns in microseconds, so the window
13175    /// between reading the gate and advancing it is too narrow to hit: removing
13176    /// [`ssa_request_locks`](SessionManager::ssa_request_locks) leaves the outcome unchanged here.
13177    /// The window is a function of that call's cost, which scales with `polys x threshold` — around a
13178    /// second at the former 8192 x 64, so roughly a quarter of one at the deployed 4096 x 32 — and a
13179    /// fixture that large is not something to build into a unit test. So this test guards the property and the
13180    /// serialisation is justified by the shape of the code rather than by this failing without it.
13181    ///
13182    /// It does earn its place: it is what would catch the gate being made per-entry, the guard being
13183    /// taken after the gate instead of before it, or the handler gaining an await between the two.
13184    #[test_log::test(tokio::test(flavor = "multi_thread", worker_threads = 4))]
13185    async fn concurrent_ssa_requests_for_one_pseudonym_admit_one_batch() -> anyhow::Result<()> {
13186        use std::collections::BTreeMap;
13187
13188        use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
13189        use hopr_protocol_start::StartInitiation;
13190
13191        const BATCH: u32 = 2;
13192        const RACERS: u32 = 4;
13193
13194        let generator = Arc::new(SsaShareGenerator::new(SsaGeneratorConfig {
13195            polynomials_per_ssa: 2,
13196            threshold: 2,
13197            surplus_shares: 1,
13198        }));
13199        let (pix_toolbox, pix_events) = PixToolbox::new(
13200            generator.clone(),
13201            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13202        );
13203        // This manager plays both halves, so its own Exit side opens a cycle and blocks on the
13204        // deposit pool. Answered on the way through; the deposits this test counts pass on unchanged.
13205        let mut pix_events = crate::testing::answering_deposit_pool(pix_events, |_| Vec::new());
13206
13207        let mgr = SessionManager::new(SessionManagerConfig {
13208            max_ssas_per_ssa_request: BATCH as usize,
13209            pix_config: IncomingSessionPixConfig {
13210                quota_range: 0..=1024 * 1024 * 1024,
13211                ..Default::default()
13212            },
13213            ..Default::default()
13214        });
13215
13216        let mut bob_transport = MockMsgSender::new();
13217        bob_transport
13218            .expect_send_message()
13219            .returning(|_, _| Box::pin(async { Ok(()) }));
13220
13221        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13222        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13223        let _notifications = tokio::spawn(async move {
13224            pin_mut!(new_session_rx);
13225            while let Some(_session) = new_session_rx.next().await {}
13226        });
13227        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
13228
13229        let pseudonym = HoprPseudonym::random();
13230        mgr.handle_incoming_session_initiation(
13231            pseudonym,
13232            StartInitiation {
13233                challenge: MIN_CHALLENGE,
13234                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13235                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13236                additional_data: small_pix_additional_data(),
13237            },
13238        )
13239        .await?;
13240
13241        let identity = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
13242            .expect("identity element must be valid");
13243        let batch = |from: u32| {
13244            (from..from + BATCH)
13245                .map(|i| (SsaIndex::new(i).expect("non-zero"), identity))
13246                .collect::<BTreeMap<_, _>>()
13247        };
13248
13249        // The opening batch, then emission far enough into its last cycle that exactly one successor
13250        // batch is legitimately admissible.
13251        mgr.handle_ssa_request(
13252            pseudonym,
13253            SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(1), []),
13254        )
13255        .await
13256        .context("the opening batch must be accepted")?;
13257        assert_eq!(BATCH as usize, drain_pix_events(&mut pix_events).await);
13258
13259        // Served for the batch, so that serialisation is the only thing deciding the outcome below.
13260        credit_returned_packets(&mgr, &pseudonym, required_returned_packets(&small_pix_params(), BATCH));
13261
13262        let min_emitted = hopr_protocol_pix::min_emission_for_early_recovery(
13263            &small_pix_params(),
13264            SsaReconstructorConfig::default().early_recovery_threshold,
13265        );
13266        let mut sent = 0u32;
13267        for _ in 0..1024 {
13268            if generator
13269                .emission_progress(&pseudonym)
13270                .is_some_and(|p| p.is_serving_last_committed() && p.front_emitted >= min_emitted)
13271            {
13272                break;
13273            }
13274            sent += 1;
13275            generator.next_share(&pseudonym, &sent.to_be_bytes())?;
13276        }
13277
13278        // Real tasks on a multi-threaded runtime, not `join_all`: `join_all` polls in order, so the
13279        // first future runs its gate check and its whole commitment phase before the second is polled
13280        // at all, and the requests never actually overlap. That shape passes with or without the
13281        // serialisation and so proves nothing about it.
13282        let barrier = Arc::new(tokio::sync::Barrier::new(RACERS as usize));
13283        let outcomes = futures::future::join_all((0..RACERS).map(|r| {
13284            let mgr = mgr.clone();
13285            let barrier = barrier.clone();
13286            let commitments = batch(BATCH + 1 + r * BATCH);
13287            tokio::spawn(async move {
13288                barrier.wait().await;
13289                mgr.handle_ssa_request(
13290                    pseudonym,
13291                    SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), commitments, []),
13292                )
13293                .await
13294            })
13295        }))
13296        .await
13297        .into_iter()
13298        .map(|joined| joined.expect("racer task must not panic"))
13299        .collect::<Vec<_>>();
13300
13301        let admitted = outcomes.iter().filter(|o| o.is_ok()).count();
13302        assert_eq!(
13303            1, admitted,
13304            "exactly one of {RACERS} concurrent requests may be admitted, got {admitted}: {outcomes:?}"
13305        );
13306        assert_eq!(
13307            BATCH as usize,
13308            drain_pix_events(&mut pix_events).await,
13309            "only the admitted batch may produce deposit instructions"
13310        );
13311
13312        bob_sender.close_channel();
13313        bob_handle.await??;
13314        Ok(())
13315    }
13316
13317    /// An `SsaRequest` asking for more than [`SessionManagerConfig::max_ssas_per_ssa_request`] SSA
13318    /// commitments must be rejected outright, before any commitment is generated or any
13319    /// `ReadyToDeposit` is emitted — and a batch *at* the configured cap must be accepted, so that
13320    /// raising the knob is what actually admits a larger batch.
13321    ///
13322    /// Each accepted entry costs a full client commitment plus its own on-chain deposit, so without
13323    /// this cap one inbound packet could amplify into up to `MAX_SSAS_PER_REQUEST` (27) deposits.
13324    #[test_log::test(tokio::test)]
13325    async fn entry_rejects_ssa_request_exceeding_configured_ssa_cap() -> anyhow::Result<()> {
13326        use std::collections::BTreeMap;
13327
13328        use hopr_crypto_packet::prelude::HoprPixGroupElement;
13329        use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
13330        use hopr_protocol_start::StartInitiation;
13331
13332        /// Outcome of offering a batch: what `handle_ssa_request` returned, how many `SessionError`
13333        /// messages the Entry sent back, and whether it kept the Session.
13334        struct Outcome {
13335            result: errors::Result<()>,
13336            session_errors: usize,
13337            session_alive: bool,
13338        }
13339
13340        // `batch` entries offered against an Entry configured to accept at most `cap`.
13341        async fn offer_batch(cap: usize, batch: u32) -> anyhow::Result<Outcome> {
13342            // The PIX event stream must stay alive: an accepted batch emits one `ReadyToDeposit` per
13343            // entry, and a dropped receiver would fail the send and mask the acceptance as an error.
13344            let (pix_toolbox, _pix_events) = PixToolbox::new(
13345                SsaShareGenerator::new(SsaGeneratorConfig {
13346                    polynomials_per_ssa: 2,
13347                    threshold: 2,
13348                    surplus_shares: 1,
13349                })
13350                .into(),
13351                SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13352            );
13353
13354            let mgr = SessionManager::new(SessionManagerConfig {
13355                pix_config: IncomingSessionPixConfig {
13356                    quota_range: 0..=1024 * 1024 * 1024,
13357                    ..Default::default()
13358                },
13359                max_ssas_per_ssa_request: cap,
13360                ..Default::default()
13361            });
13362
13363            // Count the SessionError replies: a refusal has to be *told* to the Exit, which cannot
13364            // otherwise observe it and has no path back to a new request.
13365            let session_errors = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13366            let session_errors_tx = session_errors.clone();
13367            let mut bob_transport = MockMsgSender::new();
13368            bob_transport
13369                .expect_send_message()
13370                .times(1..)
13371                .returning(move |_, data| {
13372                    if crate::testing::msg_type(&data, StartProtocolDiscriminants::SessionError) {
13373                        session_errors_tx.fetch_add(1, Ordering::Relaxed);
13374                    }
13375                    Box::pin(async { Ok(()) })
13376                });
13377
13378            let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13379            let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13380            let _notifications = tokio::spawn(async move {
13381                pin_mut!(new_session_rx);
13382                while let Some(_session) = new_session_rx.next().await {}
13383            });
13384            mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
13385
13386            let alice_pseudonym = HoprPseudonym::random();
13387
13388            mgr.handle_incoming_session_initiation(
13389                alice_pseudonym,
13390                StartInitiation {
13391                    challenge: MIN_CHALLENGE,
13392                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13393                    capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13394                    additional_data: small_pix_additional_data(),
13395                },
13396            )
13397            .await?;
13398
13399            let session_id = alice_pseudonym;
13400            let identity = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
13401                .expect("identity element must be valid");
13402
13403            // Indices start above whatever the Exit-side establishment already allocated, so the
13404            // accepted case is not rejected for non-monotonicity instead of for its size.
13405            let commitments: BTreeMap<_, _> = (100..100 + batch)
13406                .map(|i| (SsaIndex::new(i).expect("non-zero"), identity))
13407                .collect();
13408
13409            let result = mgr
13410                .handle_ssa_request(
13411                    alice_pseudonym,
13412                    SsaServerCommitmentMessage::new(
13413                        session_id,
13414                        PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE)?,
13415                        commitments,
13416                        [],
13417                    ),
13418                )
13419                .await;
13420
13421            let session_alive = mgr.active_sessions().contains(&session_id);
13422
13423            bob_sender.close_channel();
13424            bob_handle.await??;
13425
13426            Ok(Outcome {
13427                result,
13428                session_errors: session_errors.load(Ordering::Relaxed),
13429                session_alive,
13430            })
13431        }
13432
13433        // One over the default cap is rejected...
13434        let over_cap = offer_batch(
13435            DEFAULT_MAX_SSAS_PER_SSA_REQUEST,
13436            DEFAULT_MAX_SSAS_PER_SSA_REQUEST as u32 + 1,
13437        )
13438        .await?;
13439        assert!(
13440            matches!(
13441                over_cap.result,
13442                Err(TransportSessionError::Manager(SessionManagerError::Unacceptable(_)))
13443            ),
13444            "an over-cap SsaRequest must be rejected, got {:?}",
13445            over_cap.result
13446        );
13447        // ...and the refusal must be reported rather than left for the Exit to infer from its own
13448        // deposit timeout minutes later, and must not leave a Session behind that can never make PIX
13449        // progress.
13450        assert_eq!(
13451            1, over_cap.session_errors,
13452            "a refused SsaRequest must send exactly one SessionError back to the Exit"
13453        );
13454        assert!(
13455            !over_cap.session_alive,
13456            "a refused SsaRequest must tear down the Entry's half of the Session"
13457        );
13458
13459        // ...and the very same batch is accepted once the cap is raised to admit it, proving the
13460        // rejection is the configured cap talking and not some other validation.
13461        let raised = DEFAULT_MAX_SSAS_PER_SSA_REQUEST + 1;
13462        let accepted = offer_batch(raised, raised as u32).await?;
13463        assert!(
13464            accepted.result.is_ok(),
13465            "a batch at the configured cap of {raised} must be accepted, got {:?}",
13466            accepted.result
13467        );
13468        assert_eq!(
13469            0, accepted.session_errors,
13470            "an accepted batch must not send a SessionError"
13471        );
13472        assert!(
13473            accepted.session_alive,
13474            "an accepted batch must leave the Session running"
13475        );
13476
13477        Ok(())
13478    }
13479
13480    /// A retransmission request must be capped on the *cycles* it names, not only on the runs the
13481    /// wire can carry.
13482    ///
13483    /// One 8-byte run names a whole cycle, so a request naming every retained cycle is answered with
13484    /// a full commitment burst for each — thousands of packets out of a single packet in. The
13485    /// generator's per-cycle attempt cap does not bound that: it bounds repeats of one cycle, and
13486    /// each cycle here is being asked for the first time.
13487    #[test_log::test(tokio::test)]
13488    async fn entry_rejects_a_retransmission_request_naming_too_many_cycles() -> anyhow::Result<()> {
13489        use std::collections::BTreeMap;
13490
13491        use hopr_crypto_packet::prelude::HoprPixGroupElement;
13492        use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
13493        use hopr_protocol_start::StartInitiation;
13494
13495        /// Commits `cap` cycles to an Entry capped at `cap`, then asks it to retransmit `cycles` of
13496        /// them, and reports what `handle_ssa_request` made of the second request together with every
13497        /// `SsaCommit` the Entry sent across both — one per cycle at these dimensions, so the
13498        /// commitment alone accounts for `cap` of them.
13499        ///
13500        /// Committing first is what gives the assertions their teeth: an uncapped handler answers
13501        /// every *retained* index it is named before it trips over the first one it does not hold, so
13502        /// the packet count — not the error — is what separates a gate from an accident. The count is
13503        /// read only after the send channel has drained, since the bursts are queued rather than sent
13504        /// inline and a running total would race them.
13505        async fn ask_for_cycles(cap: usize, cycles: u32) -> anyhow::Result<(errors::Result<()>, usize)> {
13506            let (pix_toolbox, _pix_events) = PixToolbox::new(
13507                SsaShareGenerator::new(SsaGeneratorConfig {
13508                    polynomials_per_ssa: 2,
13509                    threshold: 2,
13510                    surplus_shares: 1,
13511                })
13512                .into(),
13513                SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13514            );
13515
13516            let mgr = SessionManager::new(SessionManagerConfig {
13517                pix_config: IncomingSessionPixConfig {
13518                    quota_range: 0..=1024 * 1024 * 1024,
13519                    ..Default::default()
13520                },
13521                max_ssas_per_ssa_request: cap,
13522                ..Default::default()
13523            });
13524
13525            // The amplification is the thing being bounded, so count what actually goes out.
13526            let commits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13527            let commits_tx = commits.clone();
13528            let mut bob_transport = MockMsgSender::new();
13529            bob_transport
13530                .expect_send_message()
13531                .times(1..)
13532                .returning(move |_, data| {
13533                    if crate::testing::msg_type(&data, StartProtocolDiscriminants::SsaCommit) {
13534                        commits_tx.fetch_add(1, Ordering::Relaxed);
13535                    }
13536                    Box::pin(async { Ok(()) })
13537                });
13538
13539            let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13540            let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13541            let _notifications = tokio::spawn(async move {
13542                pin_mut!(new_session_rx);
13543                while let Some(_session) = new_session_rx.next().await {}
13544            });
13545            mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
13546
13547            let alice_pseudonym = HoprPseudonym::random();
13548            mgr.handle_incoming_session_initiation(
13549                alice_pseudonym,
13550                StartInitiation {
13551                    challenge: MIN_CHALLENGE,
13552                    target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13553                    capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13554                    additional_data: small_pix_additional_data(),
13555                },
13556            )
13557            .await?;
13558
13559            let params = PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE)?;
13560            let identity = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
13561                .expect("identity element must be valid");
13562
13563            // Commit a full batch first, so there is real material for the retransmission to name.
13564            let commitments: BTreeMap<_, _> = (100..100 + cap as u32)
13565                .map(|i| (SsaIndex::new(i).expect("non-zero"), identity))
13566                .collect();
13567            mgr.handle_ssa_request(
13568                alice_pseudonym,
13569                SsaServerCommitmentMessage::new(alice_pseudonym, params, commitments, []),
13570            )
13571            .await?;
13572
13573            // One run apiece, each covering its whole cycle: the cheapest request to send and the
13574            // most expensive to answer.
13575            let missing = (100..100 + cycles).map(|i| (SsaIndex::new(i).expect("non-zero"), vec![(0u16, 1u16)]));
13576
13577            let result = mgr
13578                .handle_ssa_request(
13579                    alice_pseudonym,
13580                    SsaServerCommitmentMessage::recommit(alice_pseudonym, params, missing),
13581                )
13582                .await;
13583
13584            bob_sender.close_channel();
13585            bob_handle.await??;
13586
13587            Ok((result, commits.load(Ordering::Relaxed)))
13588        }
13589
13590        // One cycle over the cap is refused whole, and — the part that matters — answered with
13591        // nothing at all, rather than with a burst for each retained cycle it managed to name first.
13592        let (over_cap, over_cap_commits) = ask_for_cycles(
13593            DEFAULT_MAX_SSAS_PER_SSA_REQUEST,
13594            DEFAULT_MAX_SSAS_PER_SSA_REQUEST as u32 + 1,
13595        )
13596        .await?;
13597        assert!(
13598            matches!(
13599                over_cap,
13600                Err(TransportSessionError::Manager(SessionManagerError::Unacceptable(_)))
13601            ),
13602            "a retransmission request over the cycle cap must be refused, got {over_cap:?}"
13603        );
13604        assert_eq!(
13605            DEFAULT_MAX_SSAS_PER_SSA_REQUEST, over_cap_commits,
13606            "a refused request must add nothing to the {DEFAULT_MAX_SSAS_PER_SSA_REQUEST} messages the commitment \
13607             itself cost"
13608        );
13609
13610        // ...and the very same cycles are repaired once they fit the cap, so the refusal above is the
13611        // cap talking and not some other validation the request also trips.
13612        let (at_cap, at_cap_commits) = ask_for_cycles(
13613            DEFAULT_MAX_SSAS_PER_SSA_REQUEST,
13614            DEFAULT_MAX_SSAS_PER_SSA_REQUEST as u32,
13615        )
13616        .await?;
13617        assert!(
13618            at_cap.is_ok(),
13619            "a request at the cycle cap must be answered, got {at_cap:?}"
13620        );
13621        assert_eq!(
13622            2 * DEFAULT_MAX_SSAS_PER_SSA_REQUEST,
13623            at_cap_commits,
13624            "every named cycle must be re-sent, one message each on top of the commitment's own"
13625        );
13626
13627        Ok(())
13628    }
13629
13630    /// A batch with one undecodable member must publish nothing at all.
13631    ///
13632    /// `handle_ssa_request` used to generate, send and emit per entry, deciding each member's fate
13633    /// before looking at the next. A batch whose *second* exit commitment failed to decode had
13634    /// therefore already sent the first member's `SsaCommit` burst and emitted its `ReadyToDeposit`
13635    /// — an instruction to put money on chain — for a request that is then rejected as a whole. The
13636    /// Exit has no cycle to spend that deposit against, and the deposit key needs its half.
13637    ///
13638    /// The first member is valid and the second is not, so a per-entry implementation passes the
13639    /// "batch is refused" half of this test while still leaking the first deposit.
13640    #[test_log::test(tokio::test)]
13641    async fn a_batch_with_one_undecodable_commitment_publishes_nothing() -> anyhow::Result<()> {
13642        use std::collections::BTreeMap;
13643
13644        use futures::FutureExt;
13645        use hopr_crypto_packet::prelude::HoprPixGroupElement;
13646        use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
13647        use hopr_protocol_start::StartInitiation;
13648
13649        let (pix_toolbox, pix_events) = PixToolbox::new(
13650            SsaShareGenerator::new(SsaGeneratorConfig {
13651                polynomials_per_ssa: 2,
13652                threshold: 2,
13653                surplus_shares: 1,
13654            })
13655            .into(),
13656            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13657        );
13658
13659        let mgr = SessionManager::new(SessionManagerConfig {
13660            pix_config: IncomingSessionPixConfig {
13661                quota_range: 0..=1024 * 1024 * 1024,
13662                ..Default::default()
13663            },
13664            ..Default::default()
13665        });
13666
13667        // `SsaCommit` is the observable half of "a commitment was published"; `SessionError` is the
13668        // refusal. Both are counted, because the bug shows up as the two happening together.
13669        let commits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13670        let session_errors = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13671        let (commits_tx, errors_tx) = (commits.clone(), session_errors.clone());
13672        let mut bob_transport = MockMsgSender::new();
13673        bob_transport.expect_send_message().returning(move |_, data| {
13674            if crate::testing::msg_type(&data, StartProtocolDiscriminants::SsaCommit) {
13675                commits_tx.fetch_add(1, Ordering::Relaxed);
13676            }
13677            if crate::testing::msg_type(&data, StartProtocolDiscriminants::SessionError) {
13678                errors_tx.fetch_add(1, Ordering::Relaxed);
13679            }
13680            Box::pin(async { Ok(()) })
13681        });
13682
13683        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13684        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13685        let _notifications = tokio::spawn(async move {
13686            pin_mut!(new_session_rx);
13687            while let Some(_session) = new_session_rx.next().await {}
13688        });
13689        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
13690
13691        let alice_pseudonym = HoprPseudonym::random();
13692        mgr.handle_incoming_session_initiation(
13693            alice_pseudonym,
13694            StartInitiation {
13695                challenge: MIN_CHALLENGE,
13696                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13697                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13698                additional_data: small_pix_additional_data(),
13699            },
13700        )
13701        .await?;
13702
13703        let valid = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
13704            .expect("identity element must be valid");
13705        // All-ones is not a compressed point on the curve, so it fails before the subgroup check.
13706        // The length is taken from the type rather than written out: the group representation is
13707        // curve-dependent, and a literal silently becomes a *length* rejection under a different
13708        // `HoprPixSpec` — which is a different code path from the one under test.
13709        // Spelled out because the group repr is a `hybrid_array::Array` with several `AsRef` impls.
13710        let repr_len = AsRef::<[u8]>::as_ref(&PixGroup::<HoprPixSpec>::default().to_bytes()).len();
13711        let garbage =
13712            HoprPixGroupElement::try_from(vec![0xffu8; repr_len].as_slice()).expect("length must be accepted");
13713        assert!(
13714            garbage.try_into_pix_group().is_err(),
13715            "the test fixture must actually be undecodable"
13716        );
13717
13718        let commitments: BTreeMap<_, _> = BTreeMap::from([
13719            (SsaIndex::new(100).expect("non-zero"), valid),
13720            (SsaIndex::new(101).expect("non-zero"), garbage),
13721        ]);
13722
13723        let result = mgr
13724            .handle_ssa_request(
13725                alice_pseudonym,
13726                SsaServerCommitmentMessage::new(
13727                    alice_pseudonym,
13728                    PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE)?,
13729                    commitments,
13730                    [],
13731                ),
13732            )
13733            .await;
13734
13735        let session_alive = mgr.active_sessions().contains(&alice_pseudonym);
13736
13737        // Drain the mock transport before counting: `mock_packet_planning` delivers on a spawned
13738        // task, so a count taken here would race the sends rather than observe them.
13739        bob_sender.close_channel();
13740        bob_handle.await??;
13741
13742        assert!(
13743            matches!(
13744                result,
13745                Err(TransportSessionError::Manager(SessionManagerError::Unacceptable(_)))
13746            ),
13747            "an undecodable member must be refused, got {result:?}"
13748        );
13749        assert_eq!(
13750            0,
13751            commits.load(Ordering::Relaxed),
13752            "no SsaCommit may be sent for a batch that is refused as a whole"
13753        );
13754        assert_eq!(
13755            1,
13756            session_errors.load(Ordering::Relaxed),
13757            "the refusal must be reported to the Exit"
13758        );
13759        assert!(
13760            !session_alive,
13761            "an undecodable commitment is terminal, like every other unacceptable-parameter case"
13762        );
13763
13764        // The point of the test: not one deposit instruction escaped for the valid first member.
13765        pin_mut!(pix_events);
13766        assert!(
13767            pix_events.next().now_or_never().flatten().is_none(),
13768            "no ReadyToDeposit may be emitted for a batch that is refused as a whole"
13769        );
13770
13771        Ok(())
13772    }
13773
13774    /// The Start protocol ingress channel must be sized for the worst-case commitment burst of a
13775    /// single SSA cycle, not for the number of sessions.
13776    ///
13777    /// A dropped `SsaCommit` is unrecoverable — there is no retransmission — so the queue has to
13778    /// absorb the whole commitment set a cycle can deliver, capped by the polynomial ceiling
13779    /// `check_pix_params` enforces.
13780    #[test]
13781    fn start_protocol_channel_is_sized_for_the_worst_case_commitment_burst() {
13782        let cfg = SessionManagerConfig::default();
13783        let capacity = start_protocol_channel_capacity(&cfg);
13784
13785        // Number of commitments implied by the largest quota this node accepts, clamped by the
13786        // number of polynomials it would actually admit.
13787        let commitments = (*cfg.pix_config.quota_range.end() / PIX_QUOTA_BYTES_PER_SHARE).min(MAX_POLYS_PER_SSA as u64);
13788        let min_expected = commitments.div_ceil(MIN_COMMITMENTS_PER_SSA_COMMIT_MSG as u64) as usize;
13789
13790        assert!(
13791            capacity >= min_expected + cfg.maximum_sessions,
13792            "capacity {capacity} must cover the {min_expected}-message commitment burst plus room for {} concurrent \
13793             session setups",
13794            cfg.maximum_sessions
13795        );
13796
13797        // The commitment burst must be the term that was added, not incidental slack: the old
13798        // sizing was `maximum_sessions + 10` and carried no PIX component at all.
13799        assert_eq!(
13800            min_expected,
13801            capacity - cfg.maximum_sessions - START_PROTOCOL_CHANNEL_RESERVE,
13802            "the PIX commitment burst must be an explicit component of the capacity"
13803        );
13804        assert!(
13805            min_expected > 0,
13806            "a non-zero accepted quota must imply commitment messages"
13807        );
13808
13809        // Guards against a units mistake (e.g. counting bytes rather than messages) turning this
13810        // into a multi-gigabyte ring allocation.
13811        assert!(capacity < 1_000_000, "capacity {capacity} is implausibly large");
13812    }
13813
13814    /// `quota_range` is operator-settable and the capacity it feeds is *reserved*, not merely
13815    /// enforced — `crossfire`'s array flavour pre-allocates every slot. So the derivation must be
13816    /// bounded independently of the configured quota.
13817    ///
13818    /// Regression test: an unclamped derivation asked for ~4.2e8 slots here (77 GB), which aborted
13819    /// the whole unit test binary with an allocation failure rather than failing one assertion. The
13820    /// sizing test above never caught it because it only exercises the default config.
13821    #[test]
13822    fn start_protocol_channel_capacity_is_bounded_for_any_quota_range() {
13823        // The commitment term saturates once the quota admits more commitments than there are
13824        // polynomials, so this is the largest value it can ever take.
13825        let saturated = (MAX_POLYS_PER_SSA as u64).div_ceil(MIN_COMMITMENTS_PER_SSA_COMMIT_MSG as u64) as usize;
13826
13827        for quota_end in [10_u64.pow(13), u64::MAX] {
13828            let cfg = SessionManagerConfig {
13829                pix_config: IncomingSessionPixConfig {
13830                    quota_range: 0..=quota_end,
13831                    ..Default::default()
13832                },
13833                ..Default::default()
13834            };
13835            let capacity = start_protocol_channel_capacity(&cfg);
13836
13837            assert_eq!(
13838                capacity,
13839                saturated + cfg.maximum_sessions + START_PROTOCOL_CHANNEL_RESERVE,
13840                "quota_range end {quota_end} must not grow the commitment term past the polynomial ceiling"
13841            );
13842        }
13843
13844        // The session term is reserved for exactly the same reason, and `maximum_managed_sessions`
13845        // validates up to 100 000 — so it needs its own ceiling, not just the commitment one.
13846        let cfg = SessionManagerConfig {
13847            maximum_sessions: 100_000,
13848            ..Default::default()
13849        };
13850        assert_eq!(
13851            start_protocol_channel_capacity(&cfg),
13852            saturated + MAX_CONCURRENT_START_EXCHANGES + START_PROTOCOL_CHANNEL_RESERVE,
13853            "a large session limit must not grow the pre-allocated ring past the handshake ceiling"
13854        );
13855
13856        // The batch factor is reserved too: a batch of N draws N cycles' commitment sets into this
13857        // one channel, and a dropped `SsaCommit` is unrecoverable. It must scale the commitment term
13858        // and nothing else, and must itself be bounded by `MAX_SSA_BATCH_SIZE` even when the config
13859        // was never clamped by `SessionManager::new`.
13860        let batched = |ssas_per_request| {
13861            start_protocol_channel_capacity(&SessionManagerConfig {
13862                pix_config: IncomingSessionPixConfig {
13863                    quota_range: 0..=u64::MAX,
13864                    supervision: SupervisorConfig {
13865                        ssas_per_request,
13866                        ..Default::default()
13867                    },
13868                    ..Default::default()
13869                },
13870                ..Default::default()
13871            })
13872        };
13873        let unbatched = SessionManagerConfig::default();
13874        for ssas_per_request in [1, 2, MAX_SSA_BATCH_SIZE] {
13875            assert_eq!(
13876                batched(ssas_per_request),
13877                saturated * ssas_per_request + unbatched.maximum_sessions + START_PROTOCOL_CHANNEL_RESERVE,
13878                "the commitment term must scale with ssas_per_request = {ssas_per_request}"
13879            );
13880        }
13881        assert_eq!(
13882            batched(MAX_SSA_BATCH_SIZE + 1),
13883            batched(MAX_SSA_BATCH_SIZE),
13884            "an unclamped ssas_per_request must not inflate the pre-allocated ring"
13885        );
13886        assert_eq!(
13887            batched(0),
13888            batched(1),
13889            "a zero ssas_per_request must not collapse the commitment term"
13890        );
13891    }
13892
13893    /// Verifies that once the exit/responder (Bob) has set up the SSA state, delivering coefficient
13894    /// commits for all polynomials causes the PIX event stream to emit `DepositNeeded`.
13895    ///
13896    /// ## Steps
13897    /// 1. Bob's manager is started with a `PixToolbox` configured for `polynomials_per_ssa=2, threshold=2,
13898    ///    surplus_shares=1`. Alice's session initiation is processed normally.
13899    /// 2. The exit has already registered an exit commitment from `handle_incoming_session_initiation`.
13900    /// 3. Coefficient 0 (constant terms across all polynomials) is delivered via `handle_ssa_commit` using identity
13901    ///    group elements as dummy commitments.
13902    /// 4. Coefficient 1 (linear terms) is delivered similarly.
13903    /// 5. After the second coefficient delivery, Bob's PIX event stream emits `DepositNeeded` with the correct `SsaId`
13904    ///    and `quota_per_ssa` matching `pix_params_to_quota(2, 2)`.
13905    /// 6. The event is received within a 2-second timeout.
13906    #[test_log::test(tokio::test)]
13907    async fn exit_receives_ssa_commits_and_emits_deposit_needed_event() -> anyhow::Result<()> {
13908        use std::collections::HashMap;
13909
13910        use hopr_crypto_packet::prelude::{HoprPixCommitmentProof, HoprPixGroupElement};
13911        use hopr_protocol_pix::{
13912            Field, PixGroup, PixScalar, PolynomialIndex, SsaCommitmentProof, SsaGeneratorConfig, SsaReconstructor,
13913            SsaReconstructorConfig, SsaShareGenerator,
13914        };
13915        use hopr_protocol_start::StartInitiation;
13916
13917        let ssa_gen_config = SsaGeneratorConfig {
13918            polynomials_per_ssa: 2,
13919            threshold: 2,
13920            surplus_shares: 1,
13921        };
13922
13923        let (pix_toolbox, pix_events_rx) = PixToolbox::new(
13924            SsaShareGenerator::new(ssa_gen_config).into(),
13925            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13926        );
13927        // This test reads the event stream itself, so the pool has to be played on the way through:
13928        // the Exit blocks its SSA request on the answer, and the request would otherwise be the
13929        // first event read here.
13930        let pix_events_rx =
13931            crate::testing::answering_deposit_pool(pix_events_rx, |id| vec![id.ssa_index().get() as u8; 3]);
13932
13933        let mgr = SessionManager::new(SessionManagerConfig {
13934            pix_config: IncomingSessionPixConfig {
13935                quota_range: 0..=1024 * 1024 * 1024,
13936                ..Default::default()
13937            },
13938            ..Default::default()
13939        });
13940
13941        let mut bob_transport = MockMsgSender::new();
13942        bob_transport
13943            .expect_send_message()
13944            .returning(|_, _| Box::pin(async { Ok(()) }));
13945        // handle_incoming_session_initiation sends SessionEstablished + SsaRequest.
13946        bob_transport
13947            .expect_send_message()
13948            .returning(|_, _| Box::pin(async { Ok(()) }));
13949
13950        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13951        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13952        let _notifications = tokio::spawn(async move {
13953            pin_mut!(new_session_rx);
13954            while let Some(_session) = new_session_rx.next().await {}
13955        });
13956        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox.clone()), None)?;
13957
13958        let alice_pseudonym = HoprPseudonym::random();
13959
13960        mgr.handle_incoming_session_initiation(
13961            alice_pseudonym,
13962            StartInitiation {
13963                challenge: MIN_CHALLENGE,
13964                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13965                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13966                additional_data: small_pix_additional_data(),
13967            },
13968        )
13969        .await?;
13970
13971        // The exit commitment is registered by the supervisor's opening `RequestSsa`, which the
13972        // action driver carries out asynchronously — so wait for it rather than assume it.
13973        let ssa_id = SsaId::new(alice_pseudonym, SsaIndex::MIN);
13974        {
13975            let share_processor = pix_toolbox.share_processor.clone();
13976            tokio::time::timeout(Duration::from_secs(1), async {
13977                while !share_processor.contains_builder(&ssa_id) {
13978                    tokio::time::sleep(Duration::from_millis(10)).await;
13979                }
13980            })
13981            .await
13982            .context("the opening SSA request never registered an exit commitment")?;
13983        }
13984
13985        // Deliver coefficient 0 (constant terms across all polynomials).
13986        // Use the identity/infinity group element as a dummy commitment.
13987        // PixGroup<HoprPixSpec> = k256::ProjectivePoint, which has identity/infinity as all-zero bytes.
13988        let identity_element = {
13989            let g: PixGroup<HoprPixSpec> = Default::default();
13990            HoprPixGroupElement::try_from(g.to_bytes().as_ref()).expect("identity element must be valid")
13991        };
13992        let mut coeff_0_map = HashMap::new();
13993        for poly in 0..2 {
13994            coeff_0_map.insert(poly as PolynomialIndex, identity_element);
13995        }
13996        // The dummy constant terms sum to the identity, whose discrete logarithm is zero, so an
13997        // honest proof of knowledge over it is constructible — the Exit refuses the cycle without
13998        // one.
13999        let zero = <PixScalar<HoprPixSpec> as Field>::ZERO;
14000        let identity_proof = HoprPixCommitmentProof::from(
14001            SsaCommitmentProof::<HoprPixSpec>::prove(&ssa_id, &zero, &PixGroup::<HoprPixSpec>::default())
14002                .expect("identity proof must be constructible"),
14003        );
14004        mgr.handle_ssa_commit(
14005            alice_pseudonym,
14006            SsaClientCommitmentMessage {
14007                session_id: alice_pseudonym,
14008                ssa_index: SsaIndex::MIN,
14009                coefficient_index: 0,
14010                commitment_proof: Some(identity_proof),
14011                coefficient_commitments: coeff_0_map,
14012            },
14013        )
14014        .await?;
14015
14016        // Deliver coefficient 1 (linear terms across all polynomials).
14017        let mut coeff_1_map = HashMap::new();
14018        for poly in 0..2 {
14019            coeff_1_map.insert(poly as PolynomialIndex, identity_element);
14020        }
14021        mgr.handle_ssa_commit(
14022            alice_pseudonym,
14023            SsaClientCommitmentMessage {
14024                session_id: alice_pseudonym,
14025                ssa_index: SsaIndex::MIN,
14026                coefficient_index: 1,
14027                commitment_proof: None,
14028                coefficient_commitments: coeff_1_map,
14029            },
14030        )
14031        .await?;
14032
14033        // The first coefficient commitment should trigger DepositNeeded.
14034        pin_mut!(pix_events_rx);
14035        let event = tokio::time::timeout(std::time::Duration::from_secs(2), pix_events_rx.next())
14036            .await
14037            .map_err(|e| anyhow::anyhow!("timeout waiting for pix event: {e}"))?
14038            .ok_or_else(|| anyhow::anyhow!("pix_events_rx closed without emitting an event"))?;
14039
14040        assert!(matches!(
14041            event,
14042            HoprSessionOutPixEvent::DepositNeeded(AgreedSsaQuota { ssa_id: ref received_ssa_id, .. }, _)
14043            if received_ssa_id == &ssa_id
14044        ));
14045
14046        let HoprSessionOutPixEvent::DepositNeeded(quota, _) = event else {
14047            unreachable!();
14048        };
14049        assert_eq!(quota.quota_per_ssa, pix_params_to_quota(&small_pix_params()));
14050
14051        bob_sender.close_channel();
14052        bob_handle.await??;
14053
14054        Ok(())
14055    }
14056
14057    /// Verifies that a PIX Session whose Entry never answers the `SsaRequest` is closed when the
14058    /// commitment deadline expires.
14059    ///
14060    /// This used to be spelled as a deposit-timeout test, with `max_ssa_delivery_time: 0` and a
14061    /// 50 ms `max_deposit_wait`. A zero delivery time makes the *commitment* deadline fire
14062    /// immediately, so the assertion held whatever the deposit deadline did — it would have passed
14063    /// with that deadline removed entirely. The deposit deadline cannot be reached from this
14064    /// fixture at all: arming it needs a `CommitmentVerified`, and a mock transport has no Entry to
14065    /// send one. That path is covered end-to-end by `hopr-lib`'s `deposit_timeout_closes_session`.
14066    ///
14067    /// So `max_deposit_wait` is set far out of reach here, leaving the commitment deadline as the
14068    /// only thing that can close the Session.
14069    #[test_log::test(tokio::test)]
14070    async fn session_is_closed_when_the_entry_never_commits() -> anyhow::Result<()> {
14071        use std::time::Duration;
14072
14073        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
14074        use hopr_protocol_start::StartInitiation;
14075
14076        let ssa_gen_config = SsaGeneratorConfig {
14077            polynomials_per_ssa: 2,
14078            threshold: 2,
14079            surplus_shares: 1,
14080        };
14081
14082        let (pix_toolbox, _) = PixToolbox::new(
14083            SsaShareGenerator::new(ssa_gen_config).into(),
14084            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
14085        );
14086
14087        let mgr = SessionManager::new(SessionManagerConfig {
14088            pix_config: IncomingSessionPixConfig {
14089                quota_range: 0..=1024 * 1024 * 1024,
14090                supervision: SupervisorConfig {
14091                    // Short, so the deadline under test fires quickly.
14092                    max_ssa_delivery_time: Duration::from_millis(50),
14093                    // Scaled with it, since it has to stay under the delivery deadline. Nothing arms
14094                    // it here — the Entry sends no commitment at all, so there is no scope to
14095                    // re-request and the deadline under test is the only one that can fire.
14096                    commitment_recommit_interval: Duration::from_millis(10),
14097                    // Far out of reach, so it cannot be what closes the Session.
14098                    max_deposit_wait: Duration::from_secs(3600),
14099                    ..Default::default()
14100                },
14101                ..Default::default()
14102            },
14103            ..Default::default()
14104        });
14105
14106        let mut bob_transport = MockMsgSender::new();
14107        // handle_incoming_session_initiation sends SessionEstablished + SsaRequest (2 messages).
14108        bob_transport
14109            .expect_send_message()
14110            .returning(|_, _| Box::pin(async { Ok(()) }));
14111        bob_transport
14112            .expect_send_message()
14113            .returning(|_, _| Box::pin(async { Ok(()) }));
14114
14115        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
14116        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
14117        let _notifications = tokio::spawn(async move {
14118            pin_mut!(new_session_rx);
14119            while let Some(_session) = new_session_rx.next().await {}
14120        });
14121        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox.clone()), None)?;
14122
14123        let alice_pseudonym = HoprPseudonym::random();
14124
14125        mgr.handle_incoming_session_initiation(
14126            alice_pseudonym,
14127            StartInitiation {
14128                challenge: MIN_CHALLENGE,
14129                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
14130                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
14131                additional_data: small_pix_additional_data(),
14132            },
14133        )
14134        .await?;
14135
14136        // Session is active after establishment.
14137        assert_eq!(vec![alice_pseudonym], mgr.active_sessions());
14138
14139        // The supervisor arms the commitment deadline once the `SsaRequest` goes out and closes the
14140        // Session 50 ms later; the teardown that follows is asynchronous.
14141        tokio::time::timeout(Duration::from_secs(2), async {
14142            while mgr.num_active_sessions() > 0 {
14143                tokio::time::sleep(Duration::from_millis(10)).await;
14144            }
14145        })
14146        .await
14147        .context("session should be closed once the commitment deadline expires")?;
14148
14149        assert!(mgr.active_sessions().is_empty());
14150
14151        bob_sender.close_channel();
14152        bob_handle.await??;
14153
14154        Ok(())
14155    }
14156
14157    /// The surplus an Entry offers must survive the round trip: stored by the Exit, then echoed
14158    /// back in the `SsaRequest` it sends.
14159    ///
14160    /// It is the one negotiated value with no other consumer — it is not part of the priced quota,
14161    /// so no quota check would notice it going missing, and the Exit does not act on it yet. Every
14162    /// other dimension is pinned several times over by the checks around it.
14163    #[test_log::test(tokio::test)]
14164    async fn exit_stores_and_echoes_the_offered_surplus() -> anyhow::Result<()> {
14165        use hopr_protocol_pix::SsaReconstructorConfig;
14166        use hopr_protocol_start::StartInitiation;
14167
14168        // Distinct from every default, so a value read from local config instead of the wire is
14169        // visibly wrong rather than accidentally right.
14170        const OFFERED_SURPLUS: u8 = 37;
14171
14172        let pix_toolbox = pix_toolbox_with_pool(
14173            SsaShareGenerator::new(SsaGeneratorConfig {
14174                polynomials_per_ssa: 2,
14175                threshold: 2,
14176                surplus_shares: TEST_SURPLUS_SHARES,
14177            })
14178            .into(),
14179            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
14180        );
14181
14182        let mgr = SessionManager::new(SessionManagerConfig {
14183            pix_config: IncomingSessionPixConfig {
14184                quota_range: 0..=1024 * 1024 * 1024,
14185                ..Default::default()
14186            },
14187            ..Default::default()
14188        });
14189
14190        let echoed: Arc<std::sync::Mutex<Vec<PixParams>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
14191        let echoed_clone = echoed.clone();
14192        let mut bob_transport = MockMsgSender::new();
14193        bob_transport.expect_send_message().returning(move |_, data| {
14194            if let Ok(HoprStartProtocol::SsaRequest(req)) = HoprStartProtocol::try_from(data.data)
14195                && let Ok(params) = req.dimensions()
14196            {
14197                echoed_clone.lock().unwrap().push(params);
14198            }
14199            Box::pin(async { Ok(()) })
14200        });
14201
14202        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
14203        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
14204        let _notifications = tokio::spawn(async move {
14205            pin_mut!(new_session_rx);
14206            while let Some(_session) = new_session_rx.next().await {}
14207        });
14208        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
14209
14210        let alice_pseudonym = HoprPseudonym::random();
14211        mgr.handle_incoming_session_initiation(
14212            alice_pseudonym,
14213            StartInitiation {
14214                challenge: MIN_CHALLENGE,
14215                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
14216                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
14217                additional_data: pix_additional_data(2, 2, OFFERED_SURPLUS),
14218            },
14219        )
14220        .await?;
14221
14222        let expected = PixParams::try_new(2, 2, OFFERED_SURPLUS, LOCAL_PIX_SUITE)?;
14223
14224        let slot = mgr.sessions.get(&alice_pseudonym).context("session must exist")?;
14225        let ssa_state = slot.current_ssa_state.get().context("pix state must be set")?;
14226        assert_eq!(
14227            expected, ssa_state.params,
14228            "the Exit must keep the surplus the Entry offered, not its own"
14229        );
14230
14231        // The echo does not go out inline: the supervisor's opening `RequestSsa` is carried out by
14232        // the action driver spawned once the Session is published, which is what keeps
14233        // `SessionEstablished` ahead of `SsaRequest` on the wire.
14234        tokio::time::timeout(Duration::from_secs(1), async {
14235            while echoed.lock().unwrap().is_empty() {
14236                tokio::time::sleep(Duration::from_millis(10)).await;
14237            }
14238        })
14239        .await
14240        .context("the supervisor's opening SsaRequest never reached the wire")?;
14241
14242        bob_sender.close_channel();
14243        bob_handle.await??;
14244
14245        let echoed = echoed.lock().unwrap().clone();
14246        assert_eq!(
14247            vec![expected],
14248            echoed,
14249            "the SsaRequest must echo back exactly what was offered"
14250        );
14251
14252        Ok(())
14253    }
14254
14255    /// Verifies that `check_pix_params` rejects out-of-bounds parameters that pass the
14256    /// quota-range check but exceed the protocol limits.
14257    ///
14258    /// This is a regression test for the incentive-bypass fix (round-1 finding #1).
14259    #[test_log::test(tokio::test)]
14260    async fn check_pix_params_must_reject_invalid_bounds() -> anyhow::Result<()> {
14261        let mgr =
14262            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(SessionManagerConfig {
14263                pix_config: IncomingSessionPixConfig {
14264                    quota_range: 0..=10_000_000_000_000,
14265                    ..Default::default()
14266                },
14267                ..Default::default()
14268            });
14269
14270        let offer = |additional_data: u64| StartInitiation {
14271            challenge: 0,
14272            target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse().unwrap())),
14273            capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
14274            additional_data: into_initiation_additional_data(additional_data),
14275        };
14276        // Packed by hand rather than through `pix_additional_data`, because that helper refuses to
14277        // build the very values under test. The layout it mirrors is pinned in `PixParams`' own
14278        // tests. The suite is this build's, so that the dimension cases below fail on their
14279        // dimensions rather than on a curve mismatch — that rejection has its own test.
14280        let packed = |polys: u16, shares: u8, surplus: u8| {
14281            ((LOCAL_PIX_SUITE as u64) << 62)
14282                | ((polys as u64) << 48)
14283                | ((shares as u64) << 40)
14284                | ((surplus as u64) << 32)
14285        };
14286
14287        // polys_per_ssa > MAX_POLYS_PER_SSA (16192), and zero, with a valid quota -> should reject
14288        for polys in [0, MAX_POLYS_PER_SSA + 1, u16::MAX] {
14289            assert!(
14290                decode_and_check(&mgr, &offer(packed(polys, 128, 0))).is_none(),
14291                "should reject polys_per_ssa {polys}"
14292            );
14293        }
14294
14295        // shares_per_poly < MIN_POLY_THRESHOLD with a valid quota -> should reject. There is no
14296        // matching upper-bound case: the threshold is a byte on the wire, so `MAX_POLY_THRESHOLD`
14297        // cannot be exceeded by anything a peer is able to send.
14298        for shares in [0, 1] {
14299            assert!(
14300                decode_and_check(&mgr, &offer(packed(8192, shares, 0))).is_none(),
14301                "should reject shares_per_poly {shares}"
14302            );
14303        }
14304
14305        // Valid params should still be accepted, and arrive intact — including the surplus, which
14306        // no other check looks at and which would therefore be free to go missing.
14307        let (accepted, batch_size) =
14308            decode_and_check(&mgr, &offer(packed(8192, 128, 37))).context("should accept valid params")?;
14309        assert_eq!(PixParams::try_new(8192, 128, 37, LOCAL_PIX_SUITE)?, accepted);
14310        assert_eq!(1, batch_size);
14311
14312        // Every surplus a byte can hold is legal.
14313        for surplus in [0, 1, u8::MAX] {
14314            assert_eq!(
14315                Some((PixParams::try_new(8192, 128, surplus, LOCAL_PIX_SUITE)?, 1)),
14316                decode_and_check(&mgr, &offer(packed(8192, 128, surplus)))
14317            );
14318        }
14319
14320        Ok(())
14321    }
14322
14323    /// The Exit refuses a client whose PIX curve suite is not the one this node was built for.
14324    ///
14325    /// The curve is a build-time property on both sides and is not negotiated, so the only two
14326    /// outcomes are "same suite" and "refused". This matters because it happens *before* the Exit
14327    /// commits: every later PIX field — each coefficient commitment, the proof of knowledge — is
14328    /// sized by the curve, so a mismatch that got past here would surface as undecodable Start
14329    /// traffic on a Session both sides believed they had established.
14330    ///
14331    /// The quota range is wide open, so nothing but the suite can be doing the rejecting.
14332    #[test_log::test(tokio::test)]
14333    async fn check_pix_params_must_refuse_a_foreign_curve_suite() -> anyhow::Result<()> {
14334        let mgr =
14335            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(SessionManagerConfig {
14336                pix_config: IncomingSessionPixConfig {
14337                    quota_range: 0..=10_000_000_000_000,
14338                    ..Default::default()
14339                },
14340                ..Default::default()
14341            });
14342
14343        let offer = |suite: hopr_protocol_pix::PixSuite| StartInitiation {
14344            challenge: 0,
14345            target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse().unwrap())),
14346            capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
14347            additional_data: into_initiation_additional_data(
14348                PixParams::try_new(8192, 128, 37, suite)
14349                    .expect("test dimensions must be valid")
14350                    .into_additional_data(0),
14351            ),
14352        };
14353
14354        let foreign = match LOCAL_PIX_SUITE {
14355            hopr_protocol_pix::PixSuite::BabyJubJub => hopr_protocol_pix::PixSuite::Secp256k1,
14356            hopr_protocol_pix::PixSuite::Secp256k1 => hopr_protocol_pix::PixSuite::BabyJubJub,
14357        };
14358
14359        assert!(
14360            decode_and_check(&mgr, &offer(foreign)).is_none(),
14361            "a client offering {foreign} must be refused by a {LOCAL_PIX_SUITE} Exit"
14362        );
14363        assert!(
14364            decode_and_check(&mgr, &offer(LOCAL_PIX_SUITE)).is_some(),
14365            "and the same dimensions on this build's own curve must still be accepted"
14366        );
14367
14368        // A suite identifier no curve claims is refused as well, rather than read as a third curve.
14369        let unknown = StartInitiation {
14370            challenge: 0,
14371            target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse().unwrap())),
14372            capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
14373            additional_data: into_initiation_additional_data(
14374                (0b11u64 << 62) | (8192u64 << 48) | (128u64 << 40) | (37u64 << 32),
14375            ),
14376        };
14377        assert!(
14378            decode_and_check(&mgr, &unknown).is_none(),
14379            "an unknown suite identifier must be refused"
14380        );
14381
14382        Ok(())
14383    }
14384
14385    /// The Exit judges an offer by its quota alone; the split is the Entry's choice.
14386    ///
14387    /// The surplus is insurance against the return relayers the *Entry* picks for its SURBs, and only
14388    /// the Entry knows how far it trusts them: one expecting most of them to fail may buy
14389    /// `4096 × (2 + 102)`, which carries ~86 % share loss where the deployed split carries ~50 %. What
14390    /// the Exit has a stake in is how much it serves for one deposit, which is the quota —
14391    /// `polys × (threshold + surplus)` shares — so every split whose quota fits `quota_range` is
14392    /// accepted, whatever its ratio of surplus to threshold, and the Entry's successor gate then
14393    /// follows the tolerance it bought.
14394    ///
14395    /// Reconstructor memory is also reserved from the offered dimensions, at admission against
14396    /// `max_live_cycle_bytes`. That is a capacity check, and one the configuration validator holds to
14397    /// admitting a Session at any split of the advertised quota — not a policy on the split.
14398    #[test_log::test(tokio::test)]
14399    async fn the_exit_judges_an_offer_by_its_quota_alone() -> anyhow::Result<()> {
14400        let mgr = SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
14401        let offer = |params: PixParams| StartInitiation {
14402            challenge: 0,
14403            target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse().unwrap())),
14404            capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
14405            additional_data: into_initiation_additional_data(params.into_additional_data(0)),
14406        };
14407
14408        // The default range, in shares, is a quarter of the deployed cycle up to the whole of it:
14409        // 113 664 ..= 454 656.
14410        for (polys, threshold, surplus, quota_shares) in [
14411            (4096u16, 2u8, 102u8, 425_984u64),
14412            (1024, 2, 255, 263_168),
14413            (16192, 2, 26, 453_376),
14414            (4096, 32, 79, 454_656),
14415        ] {
14416            let params = PixParams::try_new(polys, threshold, surplus, LOCAL_PIX_SUITE)?;
14417            assert_eq!(quota_shares * PIX_QUOTA_BYTES_PER_SHARE, pix_params_to_quota(&params));
14418
14419            let (accepted, _) = decode_and_check(&mgr, &offer(params)).with_context(|| {
14420                format!("{polys} x ({threshold} + {surplus}) fits the default quota range and must be accepted")
14421            })?;
14422            assert_eq!(params, accepted, "the split must arrive as offered");
14423        }
14424
14425        // The same threshold and the most surplus the byte carries, at a polynomial count whose quota
14426        // — 1 052 672 shares — is past the top of the range. Refused, and by the quota alone.
14427        let over = PixParams::try_new(4096, 2, u8::MAX, LOCAL_PIX_SUITE)?;
14428        assert!(pix_params_to_quota(&over) > *IncomingSessionPixConfig::default().quota_range.end());
14429        assert!(
14430            decode_and_check(&mgr, &offer(over)).is_none(),
14431            "an offer whose quota exceeds the range must be refused"
14432        );
14433
14434        Ok(())
14435    }
14436
14437    /// Verifies that dispatching too many `UnverifiableShare` events closes the session.
14438    #[test_log::test(tokio::test)]
14439    async fn too_many_unverifiable_shares_closes_session() -> anyhow::Result<()> {
14440        let params = small_pix_params();
14441        let quota = pix_params_to_quota(&params);
14442        let ssa_gen_config = SsaGeneratorConfig {
14443            polynomials_per_ssa: 2,
14444            threshold: 2,
14445            surplus_shares: 1,
14446        };
14447
14448        let pix_toolbox = pix_toolbox_with_pool(
14449            SsaShareGenerator::new(ssa_gen_config).into(),
14450            SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
14451        );
14452
14453        let mgr =
14454            SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(SessionManagerConfig {
14455                pix_config: IncomingSessionPixConfig {
14456                    quota_range: quota..=quota,
14457                    max_live_cycle_bytes: max_cycle_budget_for_quota(quota, 1),
14458                    supervision: SupervisorConfig {
14459                        max_deposit_wait: Duration::from_secs(1),
14460                        ..Default::default()
14461                    },
14462                    ..Default::default()
14463                },
14464                ..Default::default()
14465            });
14466
14467        let mut bob_transport = MockMsgSender::new();
14468        bob_transport
14469            .expect_send_message()
14470            .returning(|_, _| Box::pin(async { Ok(()) }));
14471        bob_transport
14472            .expect_send_message()
14473            .returning(|_, _| Box::pin(async { Ok(()) }));
14474
14475        let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
14476        let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
14477        let _notifications = tokio::spawn(async move {
14478            pin_mut!(new_session_rx);
14479            while let Some(_session) = new_session_rx.next().await {}
14480        });
14481        mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox.clone()), None)?;
14482
14483        let alice_pseudonym = HoprPseudonym::random();
14484
14485        mgr.handle_incoming_session_initiation(
14486            alice_pseudonym,
14487            StartInitiation {
14488                challenge: MIN_CHALLENGE,
14489                target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
14490                capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
14491                additional_data: into_initiation_additional_data(params.into_additional_data(0)),
14492            },
14493        )
14494        .await?;
14495
14496        // Session is active
14497        assert_eq!(vec![alice_pseudonym], mgr.active_sessions());
14498
14499        let ssa_id = SsaId::new(alice_pseudonym, SsaIndex::new(1).expect("non-zero"));
14500
14501        // Exactly one event, reporting the absolute total the reconstructor would carry for a single
14502        // failed polynomial. There is no tolerance to exhaust first.
14503        let result = mgr
14504            .dispatch_pix_event(HoprSessionInPixEvent::UnverifiableShares {
14505                ssa_id,
14506                observed_total: 1,
14507                peer: crate::supervision::test_peer(),
14508            })
14509            .await;
14510        // Forwarding succeeds even for the event that closes: the supervisor decides, and it does so
14511        // after the send has been accepted.
14512        assert!(result.is_ok(), "dispatch_pix_event should not return an error");
14513
14514        // The supervisor's `Close` reaches the driver asynchronously, so the teardown it triggers
14515        // is observed rather than assumed.
14516        tokio::time::timeout(Duration::from_secs(1), async {
14517            while mgr.num_active_sessions() > 0 {
14518                tokio::time::sleep(Duration::from_millis(10)).await;
14519            }
14520        })
14521        .await
14522        .context("session was not closed after too many unverifiable shares")?;
14523
14524        assert!(mgr.active_sessions().is_empty());
14525
14526        bob_sender.close_channel();
14527        bob_handle.await??;
14528
14529        Ok(())
14530    }
14531
14532    /// Verifies that `allocate_session_slot` signals waiters via `.send(())` rather than
14533    /// dropping the senders. The `Ok(Ok(()))` path in `handle_ssa_request` must be live.
14534    #[test_log::test(tokio::test)]
14535    async fn allocate_session_slot_must_signal_waiters_not_cancel_them() -> anyhow::Result<()> {
14536        let session_id = HoprPseudonym::random();
14537        let mgr = SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
14538
14539        // Register a waiter before the slot is allocated
14540        let (tx, mut rx) = oneshot::channel::<()>();
14541        mgr.slot_allocated
14542            .lock()
14543            .unwrap_or_else(|e| e.into_inner())
14544            .entry(session_id)
14545            .or_default()
14546            .push(tx);
14547
14548        let (session_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
14549
14550        // Allocate the slot — this should signal the waiter
14551        let guard = mgr.allocate_session_slot(
14552            session_id,
14553            SessionSlot {
14554                session_tx,
14555                routing_opts: DestinationRouting::Return(SurbMatcher::Pseudonym(session_id)),
14556                abort_handles: Default::default(),
14557                surb_mgmt: Arc::new(BalancerStateValues::from(SurbBalancerConfig::default())),
14558                surb_estimator: Default::default(),
14559                current_ssa_state: Default::default(),
14560                pix_supervisor: Default::default(),
14561                pix_egress_gate: Default::default(),
14562                pix_fill: Default::default(),
14563                returned_packets: Default::default(),
14564                cycle_budget: None,
14565            },
14566        );
14567        assert!(guard.is_some(), "slot allocation must succeed");
14568
14569        // The waiter should receive Some(()) — the direct signal, not Canceled
14570        assert!(
14571            matches!(rx.try_recv(), Ok(Some(()))),
14572            "waiter must be signaled with Ok(()), not canceled"
14573        );
14574
14575        guard.unwrap().commit();
14576
14577        Ok(())
14578    }
14579
14580    /// What the Exit reports back to its pool must be what the Exit sent.
14581    ///
14582    /// The pool answers over a channel and nothing stops it answering for an SSA that is not in the
14583    /// batch, or for another Session entirely. Those replies are dropped from the wire; they must be
14584    /// dropped from the Exit's own memory of the batch too. Keying whole replies by `id.ssa_index()`
14585    /// did not do that: a foreign reply naming an in-batch index displaced the good entry, and
14586    /// `DepositNeeded` then handed the pool back deposit data belonging to another Session.
14587    #[test_log::test(tokio::test)]
14588    async fn a_foreign_deposit_reply_must_not_displace_what_the_exit_sent() -> anyhow::Result<()> {
14589        use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
14590
14591        let session_id: SessionId = HoprPseudonym::random();
14592        let other_session: SessionId = HoprPseudonym::random();
14593        let batch = [SsaIndex::MIN];
14594
14595        let (pix_toolbox, pix_events) = PixToolbox::new(
14596            SsaShareGenerator::new(SsaGeneratorConfig {
14597                polynomials_per_ssa: 2,
14598                threshold: 2,
14599                surplus_shares: 1,
14600            })
14601            .into(),
14602            Arc::new(SsaReconstructor::new(SsaReconstructorConfig::default())),
14603        );
14604        drop(pix_events);
14605        let _ = pix_toolbox;
14606
14607        let ssa_state = SessionSsaState::new(PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE)?);
14608
14609        // What the pool sent: the right entry, then a same-index entry belonging to another Session.
14610        let replies = vec![
14611            PixDepositData {
14612                id: PixAddressId::new(&session_id, SsaIndex::MIN),
14613                data: b"mine".as_slice().into(),
14614            },
14615            PixDepositData {
14616                id: PixAddressId::new(&other_session, SsaIndex::MIN),
14617                data: b"someone else's".as_slice().into(),
14618            },
14619        ];
14620
14621        // Only the validated map is remembered, which is the whole point.
14622        let validated = deposit_data_for_batch(&session_id, &batch, replies)?;
14623        ssa_state.remember_sent_deposit_data(&validated);
14624
14625        let recalled = ssa_state.take_sent_deposit_data(&session_id, SsaIndex::MIN);
14626        assert_eq!(
14627            b"mine".as_slice(),
14628            recalled.data.as_ref(),
14629            "the Exit must recall the payload it actually sent"
14630        );
14631        assert_eq!(
14632            PixAddressId::new(&session_id, SsaIndex::MIN),
14633            recalled.id,
14634            "the id must name this Session and this index"
14635        );
14636
14637        // Consumed exactly once; a second cycle would see empty rather than a stale entry.
14638        assert!(
14639            ssa_state.take_sent_deposit_data(&session_id, SsaIndex::MIN).is_empty(),
14640            "taking must remove the entry"
14641        );
14642
14643        Ok(())
14644    }
14645
14646    /// Runs [`request_deposit_data`](SessionManager::request_deposit_data) against a pool that answers
14647    /// with exactly what `script` returns, in that order.
14648    ///
14649    /// `script` is handed the ids the Exit asked for and returns the replies the pool will send, one
14650    /// at a time — which is what makes reply *ordering* expressible, and ordering is the only thing
14651    /// the tests below differ on from a run that never had a problem.
14652    ///
14653    /// The pool holds its sender open after the script runs out, rather than dropping it and ending
14654    /// the stream. A pool that closes the channel ends the read all by itself, which would let a test
14655    /// pass on that alone — the reason the collection stopped is exactly what these tests are about,
14656    /// so the only things left to stop it are the ones under test.
14657    async fn request_deposit_data_answered_with(
14658        session_id: SessionId,
14659        batch: &[SsaIndex],
14660        script: impl FnOnce(Vec<PixAddressId>) -> Vec<PixDepositData> + Send + 'static,
14661    ) -> errors::Result<HoprPixDepositData> {
14662        let (pix_toolbox, pix_events) = PixToolbox::new(
14663            SsaShareGenerator::new(SsaGeneratorConfig {
14664                polynomials_per_ssa: 2,
14665                threshold: 2,
14666                surplus_shares: 1,
14667            })
14668            .into(),
14669            Arc::new(SsaReconstructor::new(SsaReconstructorConfig::default())),
14670        );
14671
14672        tokio::task::spawn(async move {
14673            let mut events = Box::pin(pix_events);
14674            if let Some(HoprSessionOutPixEvent::DepositDataRequest(request)) = events.next().await {
14675                let mut created = request.deposit_data_created;
14676                for reply in script(request.deposit_ids) {
14677                    if created.send(reply).await.is_err() {
14678                        return;
14679                    }
14680                }
14681                // Held open, not dropped — see above. The task is torn down with the test's runtime.
14682                std::future::pending::<()>().await;
14683            }
14684        });
14685
14686        SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::request_deposit_data(
14687            &pix_toolbox,
14688            session_id,
14689            batch,
14690        )
14691        .await
14692    }
14693
14694    /// A reply the batch has no place for must not cost a reply that was asked for.
14695    ///
14696    /// The pool answers over one channel, and nothing about that channel separates a reply for this
14697    /// batch from another Session's. Reading a fixed number of *items* — one per requested SSA — let
14698    /// the first kind spend a slot belonging to the second, so a pool that answered every SSA and
14699    /// merely spoke out of turn lost the Session on the answer that got truncated.
14700    ///
14701    /// Ordering is the whole test: the same foreign reply arriving *after* the batch was complete
14702    /// never cost anything, because the collection had already stopped.
14703    #[test_log::test(tokio::test)]
14704    async fn a_foreign_reply_before_a_valid_one_must_not_truncate_the_batch() -> anyhow::Result<()> {
14705        let session_id: SessionId = HoprPseudonym::random();
14706        let other_session: SessionId = HoprPseudonym::random();
14707        let batch = [SsaIndex::MIN, SsaIndex::new(2).expect("non-zero")];
14708
14709        let collected = request_deposit_data_answered_with(session_id, &batch, move |ids| {
14710            std::iter::once(PixDepositData {
14711                id: PixAddressId::new(&other_session, SsaIndex::MIN),
14712                data: b"someone else's".as_slice().into(),
14713            })
14714            .chain(ids.into_iter().map(|id| PixDepositData {
14715                id,
14716                data: b"mine".as_slice().into(),
14717            }))
14718            .collect()
14719        })
14720        .await
14721        .context("a pool that answered every requested SSA must not fail the request")?;
14722
14723        let validated = deposit_data_for_batch(&session_id, &batch, collected)
14724            .context("every SSA in the batch must come out with an entry")?;
14725
14726        assert_eq!(batch.len(), validated.len(), "the whole batch must be covered");
14727        for index in batch {
14728            assert_eq!(
14729                b"mine".as_slice(),
14730                validated[&index].0.as_ref(),
14731                "index {index} must carry this Session's payload"
14732            );
14733        }
14734
14735        Ok(())
14736    }
14737
14738    /// A second answer for an SSA already answered must not cost an SSA still outstanding.
14739    ///
14740    /// Same failure as the foreign reply above, from the other direction: the repeat is a reply the
14741    /// batch has no place for, and counting items charged it against the one SSA that had not been
14742    /// answered yet. Which copy of the repeat wins is not what is under test here — that
14743    /// [`deposit_data_for_batch`] takes the later one is incidental — only that the reply after it
14744    /// still arrives.
14745    #[test_log::test(tokio::test)]
14746    async fn a_duplicate_reply_before_a_valid_one_must_not_truncate_the_batch() -> anyhow::Result<()> {
14747        let session_id: SessionId = HoprPseudonym::random();
14748        let batch = [SsaIndex::MIN, SsaIndex::new(2).expect("non-zero")];
14749
14750        let collected = request_deposit_data_answered_with(session_id, &batch, |ids| {
14751            let mut replies = ids
14752                .iter()
14753                .map(|id| PixDepositData {
14754                    id: *id,
14755                    data: vec![id.ssa_index().get() as u8].into_boxed_slice(),
14756                })
14757                .collect::<Vec<_>>();
14758            // The repeat of the first answer is inserted before the last one, so counting items
14759            // would have stopped on it and never read the SSA it was hiding.
14760            replies.insert(1, replies[0].clone());
14761            replies
14762        })
14763        .await
14764        .context("a pool that repeated itself but answered every SSA must not fail the request")?;
14765
14766        let validated = deposit_data_for_batch(&session_id, &batch, collected)
14767            .context("every SSA in the batch must come out with an entry")?;
14768
14769        assert_eq!(batch.len(), validated.len(), "the whole batch must be covered");
14770        for index in batch {
14771            assert_eq!(
14772                &[index.get() as u8],
14773                validated[&index].0.as_ref(),
14774                "index {index} must carry its own payload"
14775            );
14776        }
14777
14778        Ok(())
14779    }
14780
14781    /// Tolerating replies the batch has no place for must not become waiting for the deadline.
14782    ///
14783    /// Reading until every requested id is answered has no natural end when the pool never answers
14784    /// one, so the read is capped at twice the requested count. A pool that talks without ever
14785    /// closing the gap is cut off there and the request fails on the gap — which is the same
14786    /// verdict the deadline would reach, minus the [`DEPOSIT_DATA_REQUEST_TIMEOUT`] spent reaching
14787    /// it, with the Session held open throughout.
14788    #[test_log::test(tokio::test)]
14789    async fn a_pool_answering_only_noise_must_fail_on_the_cap_not_the_deadline() -> anyhow::Result<()> {
14790        let session_id: SessionId = HoprPseudonym::random();
14791        let other_session: SessionId = HoprPseudonym::random();
14792        let batch = [SsaIndex::MIN, SsaIndex::new(2).expect("non-zero")];
14793
14794        // Exactly the cap, and not one of them is for this Session.
14795        let noise = move |ids: Vec<PixAddressId>| {
14796            ids.iter()
14797                .flat_map(|id| {
14798                    std::iter::repeat_n(
14799                        PixDepositData {
14800                            id: PixAddressId::new(&other_session, id.ssa_index()),
14801                            data: b"someone else's".as_slice().into(),
14802                        },
14803                        2,
14804                    )
14805                })
14806                .collect()
14807        };
14808
14809        // Well under the deadline: the point is that the cap ends the read, not the clock.
14810        let result = timeout(
14811            Duration::from_secs(1),
14812            request_deposit_data_answered_with(session_id, &batch, noise),
14813        )
14814        .await
14815        .context("the cap must end the read long before DEPOSIT_DATA_REQUEST_TIMEOUT")?;
14816
14817        assert!(
14818            matches!(
14819                result,
14820                Err(TransportSessionError::Manager(SessionManagerError::MissingDepositData(
14821                    _
14822                )))
14823            ),
14824            "a pool that answered none of the requested SSAs must fail the request, got {result:?}"
14825        );
14826
14827        Ok(())
14828    }
14829
14830    /// The reply that lands exactly on the cap still counts.
14831    ///
14832    /// One reply earlier and the batch would be complete without the cap ever mattering, so this is
14833    /// the only arrangement that tells `take(2 * requested)` apart from `take(2 * requested - 1)`:
14834    /// every requested SSA is answered, but the last answer is the last reply the read will accept.
14835    #[test_log::test(tokio::test)]
14836    async fn the_last_reply_the_cap_allows_must_still_complete_the_batch() -> anyhow::Result<()> {
14837        let session_id: SessionId = HoprPseudonym::random();
14838        let other_session: SessionId = HoprPseudonym::random();
14839        let batch = [SsaIndex::MIN, SsaIndex::new(2).expect("non-zero")];
14840
14841        let collected = request_deposit_data_answered_with(session_id, &batch, move |ids| {
14842            // One unusable reply per requested id, then the answers themselves — `2 * requested`
14843            // replies in total, with the one that closes the gap sitting on the boundary.
14844            ids.iter()
14845                .map(|id| PixDepositData {
14846                    id: PixAddressId::new(&other_session, id.ssa_index()),
14847                    data: b"someone else's".as_slice().into(),
14848                })
14849                .chain(ids.iter().map(|id| PixDepositData {
14850                    id: *id,
14851                    data: vec![id.ssa_index().get() as u8].into_boxed_slice(),
14852                }))
14853                .collect()
14854        })
14855        .await
14856        .context("the answer landing on the cap must be read, not cut off by it")?;
14857
14858        let validated = deposit_data_for_batch(&session_id, &batch, collected)
14859            .context("every SSA in the batch must come out with an entry")?;
14860
14861        assert_eq!(batch.len(), validated.len(), "the whole batch must be covered");
14862        for index in batch {
14863            assert_eq!(
14864                &[index.get() as u8],
14865                validated[&index].0.as_ref(),
14866                "index {index} must carry its own payload"
14867            );
14868        }
14869
14870        Ok(())
14871    }
14872
14873    // -----------------------------------------------------------------------
14874    // PIX egress gate pressure
14875    // -----------------------------------------------------------------------
14876
14877    /// The egress path opens exactly one block episode per park, and closes it when the park ends.
14878    ///
14879    /// Asserted here rather than in the gate's own tests because the wiring is what can be wrong:
14880    /// the gate reports a verdict, and this is the only place that turns the verdict into an
14881    /// episode. A permit path that forgot to would leave `hopr_pix_gate_blocks_total` silent while
14882    /// egress was stalled -- exactly the fault the metric exists to surface.
14883    #[tokio::test]
14884    async fn a_parked_egress_packet_opens_and_closes_one_block_episode() -> anyhow::Result<()> {
14885        use crate::supervision::telemetry::probe;
14886
14887        probe::reset();
14888        // Strict prepay: the first packet is refused before anything is served.
14889        let gate = ServiceGate::new(0, 10);
14890
14891        let permit = acquire_egress_permit(
14892            Some(gate.clone()),
14893            DestinationRouting::Return(HoprPseudonym::random().into()),
14894            ApplicationDataOut::with_no_packet_info(ApplicationData::new(
14895                SESSION_APPLICATION_TAG,
14896                b"payload".as_slice(),
14897            )?),
14898        );
14899
14900        assert_eq!(
14901            1,
14902            probe::get("gate_blocks/predeposit_exhausted"),
14903            "the refusal must be counted as one episode beginning"
14904        );
14905        assert_eq!(
14906            0,
14907            probe::get("gate_block_observations/predeposit_exhausted"),
14908            "and must stay open while the packet is still parked"
14909        );
14910
14911        // Funding wakes the parked writer, which ends the episode.
14912        gate.release_service();
14913        timeout(Duration::from_secs(5), permit)
14914            .await
14915            .context("funding must wake the parked packet")?
14916            .context("a funded gate must admit it")?;
14917
14918        assert_eq!(1, probe::get("gate_blocks/predeposit_exhausted"), "still one episode");
14919        assert_eq!(
14920            1,
14921            probe::get("gate_block_observations/predeposit_exhausted"),
14922            "resuming must close the episode exactly once"
14923        );
14924        Ok(())
14925    }
14926
14927    /// Cancelling a parked packet closes its episode too.
14928    ///
14929    /// The case `Drop` exists for: a Session torn down while a writer is parked drops the permit
14930    /// future without ever resolving it, and a timer that only closed on the resume path would be
14931    /// left open for every Session that died blocked.
14932    #[tokio::test]
14933    async fn dropping_a_parked_egress_packet_closes_its_episode() -> anyhow::Result<()> {
14934        use crate::supervision::telemetry::probe;
14935
14936        probe::reset();
14937        let gate = ServiceGate::new(0, 10);
14938
14939        let permit = acquire_egress_permit(
14940            Some(gate.clone()),
14941            DestinationRouting::Return(HoprPseudonym::random().into()),
14942            ApplicationDataOut::with_no_packet_info(ApplicationData::new(
14943                SESSION_APPLICATION_TAG,
14944                b"payload".as_slice(),
14945            )?),
14946        );
14947        // Poll it once so the episode is actually begun inside the future.
14948        assert!(
14949            timeout(Duration::from_millis(50), permit).await.is_err(),
14950            "the packet must park rather than resolve"
14951        );
14952
14953        assert_eq!(1, probe::get("gate_blocks/predeposit_exhausted"));
14954        assert_eq!(
14955            1,
14956            probe::get("gate_block_observations/predeposit_exhausted"),
14957            "a cancelled park must still close its episode"
14958        );
14959        Ok(())
14960    }
14961
14962    /// A poisoned gate is counted as a refusal but opens no episode.
14963    #[tokio::test]
14964    async fn a_closed_gate_refuses_without_opening_an_episode() -> anyhow::Result<()> {
14965        use crate::supervision::telemetry::probe;
14966
14967        probe::reset();
14968        let gate = ServiceGate::new(10, 10);
14969        gate.poison();
14970
14971        let permit = acquire_egress_permit(
14972            Some(gate.clone()),
14973            DestinationRouting::Return(HoprPseudonym::random().into()),
14974            ApplicationDataOut::with_no_packet_info(ApplicationData::new(
14975                SESSION_APPLICATION_TAG,
14976                b"payload".as_slice(),
14977            )?),
14978        );
14979        assert!(permit.await.is_err(), "a poisoned gate must refuse");
14980
14981        assert_eq!(1, probe::get("gate_blocks/closed"));
14982        assert_eq!(
14983            0,
14984            probe::get("gate_block_observations/predeposit_exhausted"),
14985            "a gate that never resumes must not be timed"
14986        );
14987        Ok(())
14988    }
14989
14990    /// An un-gated Session pays nothing and is counted as nothing.
14991    #[tokio::test]
14992    async fn a_session_without_a_gate_is_not_measured() -> anyhow::Result<()> {
14993        use crate::supervision::telemetry::probe;
14994
14995        probe::reset();
14996
14997        acquire_egress_permit(
14998            None,
14999            DestinationRouting::Return(HoprPseudonym::random().into()),
15000            ApplicationDataOut::with_no_packet_info(ApplicationData::new(
15001                SESSION_APPLICATION_TAG,
15002                b"payload".as_slice(),
15003            )?),
15004        )
15005        .await
15006        .context("an un-gated packet passes straight through")?;
15007
15008        assert_eq!(
15009            std::collections::BTreeMap::new(),
15010            probe::non_zero(),
15011            "a Session that negotiated no PIX must contribute to no PIX aggregate"
15012        );
15013        Ok(())
15014    }
15015}