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(¶ms, 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("a_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(¶ms),
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(¶ms);
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(¶ms),
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(¶ms);
8464 let one_session = cycle_budget_for(¶ms, 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(¶ms, 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(¶ms, 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(¶ms);
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(¶ms, 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(¶ms, 1));
12016
12017 let at_floor = hopr_protocol_pix::min_emission_for_early_recovery(
12018 ¶ms,
12019 hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD,
12020 );
12021 let at_local = hopr_protocol_pix::min_emission_for_early_recovery(¶ms, 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 ¶ms,
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(¶ms, 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(¶ms, 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(¶ms, 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 ¶ms,
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(¶ms, 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(¶ms, 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 ¶ms,
12478 hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD,
12479 );
12480
12481 let required = required_returned_packets(¶ms, 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 ¶ms,
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(¶ms, 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(¶ms)),
12681 "the split must be one a default Exit accepts"
12682 );
12683
12684 let boundary = hopr_protocol_pix::min_emission_for_early_recovery(¶ms, 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(¶ms, 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(¶ms, 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(¶ms));
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(¶ms);
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}