hopr_transport_session/manager.rs
1use std::{
2 collections::HashMap,
3 pin::Pin,
4 sync::{Arc, Mutex, OnceLock, atomic::Ordering},
5 time::{Duration, Instant},
6};
7
8use anyhow::anyhow;
9use futures::{Sink, SinkExt, StreamExt, TryStreamExt, channel::oneshot, future::AbortHandle};
10use futures_time::future::FutureExt as TimeExt;
11use hopr_api::{
12 node::{
13 OfferedIncentivization, PixAddressId, PixDepositData, PixDepositDataRequest, SessionAdmissionDecision,
14 SessionAdmissionRequest,
15 },
16 types::{
17 crypto_random::Randomizable,
18 internal::{
19 prelude::HoprPseudonym,
20 routing::{DestinationRouting, RoutingOptions},
21 },
22 primitive::prelude::Address,
23 },
24};
25use hopr_crypto_packet::{
26 HoprPixSpec,
27 prelude::{HOPR_PIX_COMMITMENT_PROOF_SIZE, HoprPixCommitmentProof, HoprPixGroupElement},
28};
29use hopr_protocol_app::prelude::*;
30use hopr_protocol_pix::{
31 EntryShareGenerator, ExitAcknowledgementShareProcessor, GroupEncoding, MAX_POLYS_PER_SSA, PixParams, PixSpec,
32 SsaCommitmentGuard, SsaId, SsaIndex, SsaReconstructor, SsaShareGenerator,
33};
34use hopr_protocol_start::{
35 ErrorIdentifier, KeepAliveFlag, KeepAliveMessage, SsaClientCommitmentMessage, SsaServerCommitmentMessage,
36 StartChallenge, StartErrorReason, StartErrorType, StartEstablished, StartInitiation,
37};
38use hopr_utils::runtime::AbortableList;
39use tracing::{debug, error, info, trace, warn};
40
41#[cfg(feature = "telemetry")]
42use crate::telemetry::{
43 self, SessionLifecycleState, initialize_session_metrics, remove_session_metrics_state, set_session_balancer_data,
44 set_session_state,
45};
46use crate::{
47 AgreedSsaQuota, Capabilities, Capability, HoprSession, HoprSessionOutPixEvent, IncomingSession, SESSION_MTU,
48 SessionClientConfig, SessionId, SessionTarget, SurbBalancerConfig,
49 balancer::{
50 AtomicSurbFlowEstimator, BalancerStateValues, MAX_WAIT_CHUNK, RateController, RateLimitSinkExt, SurbBalancer,
51 SurbControllerWithCorrection, SurbFlowEstimator,
52 pid::{PidBalancerController, PidControllerGains},
53 simple::SimpleBalancerController,
54 },
55 errors::{self, SessionManagerError, TransportSessionError},
56 supervision::{
57 ActionRx, FillRate, GateVerdict, ServiceGate, SessionPixAction, SessionPixCloseReason, SessionPixEvent,
58 SessionPixSupervisorHandle, SupervisorConfig, spawn_supervisor_worker,
59 telemetry::{PixAdmissionRejection, PixGateBlockEpisode, record_gate_closed},
60 },
61 types::{
62 ClosureReason, DEFAULT_PIX_PARAMS, DEFAULT_PIX_QUOTA_RANGE_SPAN, DEFAULT_PIX_SSA_QUOTA, HoprPixDepositData,
63 HoprPixDepositPayload, HoprSessionCapabilities, HoprSessionConfig, HoprSessionInPixEvent, HoprStartProtocol,
64 LOCAL_PIX_SUITE, PIX_QUOTA_BYTES_PER_SHARE, SESSION_APPLICATION_TAG, SessionAdmissionSink, SsaQuota,
65 deposit_data_for_batch, pix_params_to_quota,
66 },
67 utils,
68 utils::{SurbNotificationMode, insert_into_next_slot},
69};
70
71#[cfg(all(feature = "telemetry", not(test)))]
72lazy_static::lazy_static! {
73 static ref METRIC_ACTIVE_SESSIONS: hopr_api::types::telemetry::SimpleGauge = hopr_api::types::telemetry::SimpleGauge::new(
74 "hopr_session_num_active_sessions",
75 "Number of currently active HOPR sessions"
76 ).unwrap();
77 static ref METRIC_NUM_ESTABLISHED_SESSIONS: hopr_api::types::telemetry::SimpleCounter = hopr_api::types::telemetry::SimpleCounter::new(
78 "hopr_session_established_sessions_count",
79 "Number of sessions that were successfully established as an Exit node"
80 ).unwrap();
81 static ref METRIC_NUM_INITIATED_SESSIONS: hopr_api::types::telemetry::SimpleCounter = hopr_api::types::telemetry::SimpleCounter::new(
82 "hopr_session_initiated_sessions_count",
83 "Number of sessions that were successfully initiated as an Entry node"
84 ).unwrap();
85 static ref METRIC_RECEIVED_SESSION_ERRS: hopr_api::types::telemetry::MultiCounter = hopr_api::types::telemetry::MultiCounter::new(
86 "hopr_session_received_error_count",
87 "Number of HOPR session errors received from an Exit node",
88 &["kind"]
89 ).unwrap();
90 static ref METRIC_DISPATCHED_MSGS: hopr_api::types::telemetry::MultiCounter = hopr_api::types::telemetry::MultiCounter::new(
91 "hopr_session_dispatched_messages",
92 "Number dispatched HOPR session messages and their classification",
93 &["kind"]
94 ).unwrap();
95 static ref METRIC_SENT_SESSION_ERRS: hopr_api::types::telemetry::MultiCounter = hopr_api::types::telemetry::MultiCounter::new(
96 "hopr_session_sent_error_count",
97 "Number of HOPR session errors sent to an Entry node",
98 &["kind"]
99 ).unwrap();
100}
101
102/// One outgoing data packet on its way to the wire.
103type EgressItem = (DestinationRouting, ApplicationDataOut);
104
105/// The result of asking the PIX egress gate for permission to send one packet.
106///
107/// `Left` is the answer the gate could give synchronously — a permit, or a refusal because the
108/// Session is being torn down. `Right` is the parked case, and the only one that allocates.
109type EgressPermit = futures::future::Either<
110 std::future::Ready<Result<EgressItem, std::io::Error>>,
111 futures::future::BoxFuture<'static, Result<EgressItem, std::io::Error>>,
112>;
113
114/// Passes one outgoing data packet through a Session's PIX egress gate, if it has one.
115///
116/// Returns a future rather than awaiting so this can sit in a `Sink::with` without the combinator
117/// having to box anything in the common case. The gate answers synchronously whenever service is
118/// available — a relaxed load and a compare-exchange — and only the exhausted-budget path allocates,
119/// which is a path that is about to block regardless.
120///
121/// A Session that negotiated no PIX passes through with no gate at all, so an un-supervised Session
122/// pays one `Option` check per packet and nothing else.
123///
124/// Nothing is measured on the admitted arm, deliberately. That arm is the steady state at the
125/// Session's full packet rate, and what an operator wants from it — how much was served, and on
126/// whose money — is already counted inside the gate's own compare-exchange and flushed once per
127/// supervisor turn by [`PixSessionTelemetry`](crate::supervision::telemetry::PixSessionTelemetry).
128/// Only the refused arm, which is about to allocate a boxed future and block, does any telemetry
129/// work of its own.
130fn acquire_egress_permit(
131 gate: Option<Arc<ServiceGate>>,
132 routing: DestinationRouting,
133 data: ApplicationDataOut,
134) -> EgressPermit {
135 let Some(gate) = gate else {
136 return futures::future::Either::Left(std::future::ready(Ok((routing, data))));
137 };
138
139 match gate.try_acquire_sync() {
140 Ok(GateVerdict::Admitted) => futures::future::Either::Left(std::future::ready(Ok((routing, data)))),
141 // Forwarded rather than reconstructed: the gate now names its own error, so both of its
142 // entry points report the same one and neither caller has to know what a refusal means.
143 Err(closed) => {
144 record_gate_closed();
145 futures::future::Either::Left(std::future::ready(Err(std::io::Error::other(closed))))
146 }
147 // Budget exhausted: park until the supervisor funds the front, restores a successor's
148 // allowance, reports front-cycle progress, or gives up on the Session entirely.
149 //
150 // The episode is opened *here*, at the refusal, and moved into the future, so its clock
151 // covers the whole park and its `Drop` closes it however the park ends — resumed, failed,
152 // or cancelled. Opened outside the `async move` rather than inside it because the refusal
153 // has already happened by the time this returns: a future that is constructed and never
154 // polled is still a packet the gate turned away, and starting the clock at first poll
155 // would under-report every stall by however long the caller took to await it.
156 //
157 // One episode per park rather than per refused packet: this sink is serial, so the next
158 // packet is not offered until this future resolves, and a caller cannot open a second
159 // episode by retrying harder.
160 Ok(GateVerdict::Blocked(reason)) => {
161 let episode = PixGateBlockEpisode::begin(reason);
162 futures::future::Either::Right(Box::pin(async move {
163 let _episode = episode;
164 gate.acquire()
165 .await
166 .map(|_| (routing, data))
167 .map_err(std::io::Error::other)
168 }))
169 }
170 }
171}
172
173/// Closes an established Session.
174///
175/// Logs the counterpart of "new session established" (see [`SessionSlotGuard::commit`]): one line
176/// per Session at INFO, so its whole lifetime can be read from a production log without the
177/// per-frame detail of the socket.
178fn close_session(session_id: SessionId, session_data: SessionSlot, reason: ClosureReason) {
179 tracing::info!(%session_id, %reason, "session closed");
180 release_session_slot(session_id, session_data, reason);
181}
182
183/// Tears down a Session slot: stops its tasks and returns what it holds.
184///
185/// Logs nothing at INFO, because it also cleans up a Session whose setup failed before it was
186/// established (see [`SessionSlotGuard`]). An established Session is closed with [`close_session`].
187#[tracing::instrument(level = "debug", skip(session_data))]
188fn release_session_slot(session_id: SessionId, session_data: SessionSlot, reason: ClosureReason) {
189 debug!("closing session");
190
191 #[cfg(feature = "telemetry")]
192 {
193 set_session_state(&session_id, SessionLifecycleState::Closed);
194 remove_session_metrics_state(&session_id, session_data.pix_egress_gate.get().is_some());
195 }
196
197 if reason != ClosureReason::EmptyRead {
198 // Closing the data sender will also cause it to close from the read side
199 debug!("data tx channel closed on session");
200 }
201
202 // Poison the egress gate before aborting anything. A writer parked on an exhausted predeposit
203 // budget is waiting for a supervisor that is about to stop existing, so it has to be failed
204 // rather than left pending — aborting the tasks first would remove the only thing that could
205 // still have woken it.
206 if let Some(gate) = session_data.pix_egress_gate.get() {
207 gate.poison();
208 }
209
210 // Return this Session's share of the node-level PIX aggregates, after the gate is poisoned so
211 // that no permit it was still handing out is missed. Explicit rather than left to `Drop` for the
212 // reason `CycleBudgetReservation` gives below, and one more: the supervisor worker is spawned
213 // detached and stops only when the last command sender goes with the slot — which the cache does
214 // during a later maintenance pass. Until then the node would report Sessions and cycles that no
215 // longer exist. Idempotent, so the `Drop` backstop is free to run afterwards.
216 if let Some(supervisor) = session_data.pix_supervisor.get() {
217 supervisor.telemetry.release();
218 }
219
220 // Terminate any additional tasks spawned by the Session. This is also what releases the PIX
221 // reconstructor state: the action driver holds a commitment guard per live cycle, and aborting
222 // it drops them.
223 session_data.abort_handles.lock().abort_all();
224
225 // And return the memory those cycles were admitted against, now rather than whenever the last
226 // clone of this slot happens to be dropped. Idempotent, so the slot's own `Drop` — including
227 // the cache's deferred one — is free to run afterwards.
228 if let Some(reservation) = session_data.cycle_budget.as_ref() {
229 reservation.release();
230 }
231
232 #[cfg(all(feature = "telemetry", not(test)))]
233 METRIC_ACTIVE_SESSIONS.decrement(1.0);
234}
235
236/// How a close is answered for a Session whose SURB buffer is already draining.
237///
238/// A `WriteClosed`/`EmptyRead` is the end-of-stream our own ingress abort produces, so it is absorbed
239/// and the drain continues; anything else — a second operator close, or a peer `SessionError` —
240/// overrules the drain by falling through to the hard teardown.
241///
242/// [`begin_surb_drain`](SessionManager::begin_surb_drain) asks it twice, because a Session can be
243/// found draining either before the transition or by losing it to a concurrent close.
244fn answer_while_draining(reason: ClosureReason) -> Option<bool> {
245 matches!(reason, ClosureReason::WriteClosed | ClosureReason::EmptyRead).then_some(true)
246}
247
248fn initiation_timeout_max_one_way(base: Duration, hops: usize) -> Duration {
249 base * (hops as u32)
250}
251
252/// Conservative lower bound on how many coefficient commitments fit into one `SsaCommit` message.
253///
254/// Mirrors the sizing in `SsaClientCommitmentMessage::new_multiple`: payload minus the fixed
255/// prefix (`ssa_index` + `coefficient_index` + `num_polys` + the Start header) and minus a generous
256/// allowance for the CBOR-encoded session id, divided by the per-entry cost
257/// (`PolynomialIndex` + one serialized group element). Using a *lower* bound here means the derived
258/// message count is an over-estimate, which is the safe direction for sizing a queue.
259///
260/// The commitment proof of knowledge is subtracted as well. Every message carries it, since every
261/// message is a constant-term message.
262const MIN_COMMITMENTS_PER_SSA_COMMIT_MSG: usize = {
263 const FIXED_PREFIX: usize = 12;
264 // A `SessionId` is a 10-byte pseudonym; 64 bytes is a large allowance for its CBOR framing.
265 const CBOR_SESSION_ID_ALLOWANCE: usize = 64;
266 const PER_ENTRY: usize = size_of::<hopr_protocol_pix::PolynomialIndex>() + size_of::<HoprPixGroupElement>();
267
268 let usable = ApplicationData::PAYLOAD_SIZE
269 .saturating_sub(FIXED_PREFIX + CBOR_SESSION_ID_ALLOWANCE + HOPR_PIX_COMMITMENT_PROOF_SIZE);
270 let per_msg = usable / PER_ENTRY;
271 if per_msg == 0 { 1 } else { per_msg }
272};
273
274/// Slack added on top of the PIX commitment burst to cover non-PIX Start protocol traffic
275/// (session initiations, establishments, errors, keep-alives).
276const START_PROTOCOL_CHANNEL_RESERVE: usize = 10;
277
278/// Ceiling on the per-session term of [`start_protocol_channel_capacity`].
279///
280/// A session contributes to this channel only while its Start exchange is in flight — a handful of
281/// messages between initiation and establishment — after which it is silent apart from PIX, which
282/// the commitment term already covers. So the queue depth tracks concurrent *handshakes*, not the
283/// total number of sessions the node may manage, and `maximum_managed_sessions` (validated up to
284/// 100 000) is the wrong quantity to size a pre-allocated ring from.
285const MAX_CONCURRENT_START_EXCHANGES: usize = 10_000;
286
287/// Capacity of the Start protocol ingress channel.
288///
289/// This channel is fed by [`SessionManager::dispatch_message`] with `try_send`, and an overflow
290/// **drops** the message. For most Start messages that is recoverable, but a dropped `SsaCommit`
291/// is not: there is no NACK or retransmission, so the corresponding coefficient cell stays empty
292/// forever, the commitment never completes, every subsequent share fails to verify, and the cycle
293/// dies on the supervisor's `max_deposit_wait` deadline.
294///
295/// PIX changed this channel's load from roughly one message per session to the *entire* commitment
296/// set of an SSA cycle, chunked into packet-sized messages, plus a reserve for ordinary Start
297/// traffic. Batching multiplies that: an Exit may ask for up to
298/// [`ssas_per_request`](crate::SupervisorConfig::ssas_per_request) SSAs at once and gets that many
299/// cycles' commitment sets back-to-back, all landing here, so the per-cycle term is scaled by the
300/// configured maximum even when dynamic admission selects less for a particular Session.
301///
302/// The per-cycle burst is bounded by two independent limits, and the capacity takes the smaller:
303///
304/// * `quota_range.end() / PIX_QUOTA_BYTES_PER_SHARE` is `polys × (threshold + surplus)`, an over-estimate by that whole
305/// second factor, since a cycle carries one constant term per polynomial and nothing else. The quota alone does not
306/// reveal how the product splits, so the over-estimate cannot be undone from it.
307/// * [`MAX_POLYS_PER_SSA`] is the number of polynomials [`SessionManager::check_pix_params`] will accept, whatever the
308/// quota says. It therefore bounds the commitments a cycle can ever deliver.
309///
310/// Clamping to the second matters because this capacity is *reserved*, not merely enforced:
311/// `crossfire`'s array flavour pre-allocates every slot when the channel is built. An
312/// operator-settable `quota_range` feeding an unclamped derivation is an allocation with no upper
313/// bound — at `quota_range.end() = 1e13` it asks for 77 GB. Over-provisioning is still the safe
314/// direction within the clamp, and the surviving margin is large: the default dimensions burst
315/// ≈ 320 messages against a capacity term of ≈ 648.
316///
317/// The batch factor is bounded by [`MAX_SSA_BATCH_SIZE`] for the same allocation reason, and is
318/// clamped here rather than being taken on trust from the config, so that callers which build a
319/// `SessionManagerConfig` without going through [`SessionManager::new`] cannot inflate it.
320///
321/// The session term is clamped for the same reason. `maximum_managed_sessions` validates up to
322/// 100 000, and one slot holds a `(HoprPseudonym, HoprStartProtocol)` sized by the enum's largest
323/// variant, so an operator raising the session limit would silently buy a multi-megabyte startup
324/// allocation. Ordinary Start traffic is one message per session *in flight*, not one per session
325/// the node will ever manage, so [`MAX_CONCURRENT_START_EXCHANGES`] is the honest bound.
326fn start_protocol_channel_capacity(cfg: &SessionManagerConfig) -> usize {
327 let max_commitments = (*cfg.pix_config.quota_range.end() / PIX_QUOTA_BYTES_PER_SHARE).min(MAX_POLYS_PER_SSA as u64);
328 let max_commit_msgs = max_commitments.div_ceil(MIN_COMMITMENTS_PER_SSA_COMMIT_MSG as u64);
329 let ssas_per_request = cfg.pix_config.supervision.ssas_per_request.clamp(1, MAX_SSA_BATCH_SIZE) as u64;
330
331 // `usize::try_from` cannot fail on 64-bit targets; saturate rather than panic on 32-bit ones.
332 usize::try_from(max_commit_msgs.saturating_mul(ssas_per_request))
333 .unwrap_or(usize::MAX)
334 .saturating_add(cfg.maximum_sessions.min(MAX_CONCURRENT_START_EXCHANGES))
335 .saturating_add(START_PROTOCOL_CHANNEL_RESERVE)
336}
337
338/// Minimum time the SURB buffer must endure if no SURBs are being produced.
339pub const MIN_SURB_BUFFER_DURATION: Duration = Duration::from_secs(1);
340/// Minimum time between SURB buffer notifications to the Entry.
341pub const MIN_SURB_BUFFER_NOTIFICATION_PERIOD: Duration = Duration::from_secs(1);
342
343/// Fastest return-path rate a Session is assumed to sustain, in packets per second.
344///
345/// Bounds the PIX `max_recovery_time` from below: a deadline this rate cannot meet is treated as one
346/// no Session can, so without fill it is the floor itself, and with fill it caps the `fill.max_rate`
347/// the floor may count on. Stated in packets, because per-packet work is what limits a Session, so it does not
348/// move with any packet or segment size; it carries ≈ 58 Mbps of Session data at full
349/// [`SESSION_MTU`] segments.
350pub const MAX_ASSUMED_SESSION_PACKET_RATE: u64 = 5000;
351
352/// The first challenge value used in Start protocol to initiate a session.
353pub(crate) const MIN_CHALLENGE: StartChallenge = 1;
354
355/// Maximum time to wait for counterparty to receive the target number of SURBs.
356const SESSION_READINESS_TIMEOUT: Duration = Duration::from_secs(10);
357
358/// How long the Entry holds a *near-miss* `SsaRequest` while the Exit's returned data catches up to
359/// the successor boundary.
360///
361/// Absorbs mixer reordering between the returned packets and the `SsaRequest` they earned — the two
362/// travel the same mixed path, so the request can arrive ahead of the last few packets that
363/// justified it. Nothing longer: a sustained shortfall never reaches this wait, because the gate only
364/// enters it for a request already within one emission window of the boundary.
365///
366/// Comfortably inside the Exit's `max_ssa_delivery_time` (20 s by default, batch-scaled), which is
367/// what closes the Session as `CommitmentTimeout` if no `SsaCommit` follows. That budget also has to
368/// cover generating the commitments and shipping the burst, so this takes a small slice of it.
369const SSA_SUCCESSOR_SERVICE_WAIT: Duration = Duration::from_secs(2);
370
371/// How often [`SSA_SUCCESSOR_SERVICE_WAIT`] re-reads the returned-packet count.
372///
373/// Polled rather than notified: the alternative is waking a waiter from the Session receive path,
374/// which would put a branch on every inbound packet to serve a case that arises once per SSA cycle.
375const SSA_SUCCESSOR_SERVICE_POLL: Duration = Duration::from_millis(250);
376
377/// Minimum timeout until an unfinished frame is discarded.
378const MIN_FRAME_TIMEOUT: Duration = Duration::from_millis(10);
379
380/// Hard ceiling on both SSA batch-size knobs, whatever the configuration says.
381///
382/// The current maximum value of 9 is based on the current available pool limitations.
383///
384/// Deliberately far below the wire limit (`StartProtocol::MAX_SSAS_PER_REQUEST`, 27), which only
385/// bounds what can be *decoded*. The real cost is paid on both sides of the exchange and neither is
386/// small at the profiled dimensions:
387///
388/// * Entry: every entry in the batch is a full `new_ssa_commitment` (hundreds of thousands of EC commitments), its own
389/// burst of thousands of `SsaCommit` packets, and its own `ReadyToDeposit` — i.e. its own on-chain deposit.
390/// * Exit: every entry is a live reconstructor cycle, held until that cycle recovers — worst case ≈41 MiB at the
391/// deployed dimensions (`hopr_protocol_pix::peak_cycle_bytes`), so ≈820 MiB per Session at this ceiling, before the
392/// pipelining factor. That is what a Session reserves against [`IncomingSessionPixConfig::max_live_cycle_bytes`], so
393/// raising the batch size directly divides how many PIX Sessions the node will accept.
394///
395/// It also bounds the supervisor's deadline scaling: a batch multiplies both
396/// [`max_ssa_delivery_time`](crate::SupervisorConfig::max_ssa_delivery_time) and
397/// [`max_deposit_wait`](crate::SupervisorConfig::max_deposit_wait), and that product is what decides
398/// how long a Session may be served unincentivized.
399///
400/// Both [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request) and
401/// [`SessionManagerConfig::max_ssas_per_ssa_request`] are clamped to `1..=Self` where they are read,
402/// so a programmatically built config that never calls `validate()` cannot exceed it.
403pub const MAX_SSA_BATCH_SIZE: usize = 9;
404
405// The Entry's retention of commitment material has to outlast the batch that material belongs to.
406// `hopr-protocol-pix` cannot see this figure, so it derives its cap from it in prose; this is the
407// half of that derivation which has to hold. A cycle must stay retransmittable at least until its
408// own batch has finished being committed, or a commitment packet lost early in a large batch becomes
409// unrepairable while the rest of the batch is still arriving. Slack beyond one batch is a judgement
410// call, which is why this is an inequality and not the equality the two figures satisfy today.
411const _: () = assert!(
412 hopr_protocol_pix::MAX_RETAINED_COMMITMENT_CYCLES >= MAX_SSA_BATCH_SIZE,
413 "MAX_SSA_BATCH_SIZE exceeds MAX_RETAINED_COMMITMENT_CYCLES, so a cycle can be evicted from the Entry's retention \
414 before its own batch is fully committed and its commitment stops being repairable"
415);
416
417/// How long an Exit waits for the deposit pool to answer a
418/// [`DepositDataRequest`](HoprSessionOutPixEvent::DepositDataRequest) before sending the batch
419/// without what has not arrived.
420pub const DEPOSIT_DATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
421
422/// SSA batches whose reconstructor state can be live on the Exit at the same moment.
423///
424/// Two, enforced at allocation rather than inferred from share-order policy. The supervisor tags
425/// every requested generation and defers a successor while this many generations still own live
426/// reconstructor state; it also checks the equivalent full-batch cycle count charged below. Full
427/// recovery releases cycle state immediately, after which a deferred request is retried.
428///
429/// Used as the pipelining factor when a Session reserves against
430/// [`IncomingSessionPixConfig::max_live_cycle_bytes`].
431pub const MAX_OVERLAPPING_BATCHES: u64 = 2;
432
433/// What a PIX Session at these dimensions costs the node's live-cycle budget.
434///
435/// The worst case one cycle can hold, times every cycle that can be live at once. Both factors come
436/// from configuration rather than observation, because the charge is made before the Session exists.
437/// `ssas_per_request` is clamped for the reason [`SessionManager::new`] clamps it — a
438/// programmatically built config must not be able to understate the reservation and then overrun it.
439pub fn cycle_budget_for(params: &PixParams, ssas_per_request: usize) -> u64 {
440 hopr_protocol_pix::peak_cycle_bytes::<HoprPixSpec>(params)
441 .saturating_mul(ssas_per_request.clamp(1, MAX_SSA_BATCH_SIZE) as u64)
442 .saturating_mul(MAX_OVERLAPPING_BATCHES)
443}
444
445/// The largest reservation any offer inside a `quota_range` ending at `quota_bytes` can produce.
446///
447/// The quota fixes `polys × (threshold + surplus)` but not the split, and the two terms of
448/// [`hopr_protocol_pix::peak_cycle_bytes`] pull opposite ways — the share buffers want a high
449/// threshold, the per-polynomial overhead wants many polynomials — so the maximum is found by
450/// walking the thresholds rather than by a closed form. 254 iterations, once, at config load.
451///
452/// `surplus = 0` throughout, which is what makes each candidate the worst of its threshold: the
453/// surplus is priced into the quota but holds no reconstructor state, so any surplus at all buys
454/// fewer polynomials for the same quota.
455///
456/// Used to reject a [`IncomingSessionPixConfig::max_live_cycle_bytes`] that could never admit even
457/// one Session at the dimensions its own `quota_range` advertises.
458pub fn max_cycle_budget_for_quota(quota_bytes: u64, ssas_per_request: usize) -> u64 {
459 let quota_shares = quota_bytes / PIX_QUOTA_BYTES_PER_SHARE;
460
461 (hopr_protocol_pix::MIN_POLY_THRESHOLD..=hopr_protocol_pix::MAX_POLY_THRESHOLD)
462 .filter_map(|threshold| {
463 let polys = u16::try_from((quota_shares / threshold as u64).min(MAX_POLYS_PER_SSA as u64)).ok()?;
464 PixParams::try_new(polys, threshold, 0, LOCAL_PIX_SUITE).ok()
465 })
466 .map(|params| cycle_budget_for(¶ms, ssas_per_request))
467 .max()
468 .unwrap_or_default()
469}
470
471/// Default for [`SessionManagerConfig::max_ssas_per_ssa_request`] — how many SSA commitments an Entry
472/// accepts in a single [`SsaServerCommitmentMessage`].
473///
474/// Pipelining needs at most one cycle in flight ahead of the active one, so 2 leaves room for an Exit
475/// batching at the default without turning one inbound packet into an unbounded amount of Entry work
476/// and on-chain deposits.
477pub const DEFAULT_MAX_SSAS_PER_SSA_REQUEST: usize = 2;
478
479/// Default for [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request) —
480/// how many SSAs an Exit may ask for in a single [`SsaServerCommitmentMessage`]. It is the maximum
481/// in dynamic mode and the exact batch in fixed mode.
482///
483/// One, so that the default configuration produces exactly the unbatched exchange: same wire bytes,
484/// same supervisor deadlines, same Start protocol channel capacity.
485pub const DEFAULT_SSAS_PER_SSA_REQUEST: usize = 1;
486
487/// Timeout when sending Start protocol messages to the sink
488const EXTERNAL_SEND_TIMEOUT: Duration = Duration::from_millis(200);
489
490// Needs to use an UnboundedSender instead of oneshot
491// because Moka cache requires the value to be Clone, which oneshot Sender is not.
492// It also cannot be enclosed in an Arc, since calling `send` consumes the oneshot Sender.
493// The Session initiation cache is only present on the Entry (client) side.
494type SessionInitiationCache = moka::sync::Cache<
495 StartChallenge,
496 crossfire::MTx<crossfire::mpsc::One<Result<StartEstablished<SessionId>, StartErrorType<SessionId>>>>,
497>;
498
499/// Handles to streams and tasks spawned by the Session.
500#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
501enum SessionHandles {
502 /// Handle to the stream that facilitates ingress of data from the HOPR network into the Session.
503 Ingress,
504 /// Handle to the process that sends keep-alive messages to the Session recipient (Exit).
505 KeepAlive,
506 /// Handle to the one-shot timer that turns the SURB-level notification on after its first period.
507 ///
508 /// Registered rather than fired and forgotten: it holds an `Arc<PixFillControl>` for as long as
509 /// the period lasts, and no maximum is enforced on that period, so an unregistered task keeps a
510 /// closed Session's fill control alive for however long the operator configured.
511 SurbNotifyDelay,
512 /// Handle to the process that monitors and balances SURBs.
513 Balancer,
514 /// Handle to the task that executes the PIX supervisor's actions.
515 ///
516 /// One per Session rather than one per cycle: the supervisor multiplexes every SSA it tracks
517 /// onto a single action stream.
518 PixActionDriver,
519 /// Handle to the process that awaits the PIX deposit for one SSA.
520 ///
521 /// Carries the inner `SsaIndex` value so that each cycle's observer is independent — pipelining
522 /// must not cancel an earlier cycle's.
523 PixDepositObserver(u32),
524}
525
526impl std::fmt::Display for SessionHandles {
527 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528 match self {
529 Self::Ingress => write!(f, "Ingress"),
530 Self::KeepAlive => write!(f, "KeepAlive"),
531 Self::SurbNotifyDelay => write!(f, "SurbNotifyDelay"),
532 Self::Balancer => write!(f, "Balancer"),
533 Self::PixActionDriver => write!(f, "PixActionDriver"),
534 Self::PixDepositObserver(idx) => write!(f, "PixDepositObserver({idx})"),
535 }
536 }
537}
538
539/// The PIX dimensions this Session negotiated, on the Exit side.
540///
541/// Once carried the SSA index and a fault counter as well. Both moved to the supervisor: it decides
542/// *when* a cycle is requested, so it is also what allocates the index, and it is what enforces the
543/// fault limit. Leaving a second copy of either here would have meant two authorities for one fact.
544#[derive(Debug)]
545struct SessionSsaState {
546 /// The dimensions this Session negotiated, as they went on the wire.
547 params: PixParams,
548 /// Highest SSA index this Session has had the generator commit to, `0` before the first batch.
549 ///
550 /// A deliberate second copy of a fact the generator already holds, and the only one of the three
551 /// that came back. The generator's own watermark lives in a cache with an idle retention, so
552 /// "absent" there conflates *never committed* with *state discarded* — and those must not be
553 /// treated alike, because the first is the opening batch every Session begins with and the second
554 /// is a Session whose Entry can no longer serve the cycles it already committed to. This copy
555 /// lives exactly as long as the Session does, so it can tell them apart. See the successor gate in
556 /// [`SessionManager::handle_ssa_request`].
557 ///
558 /// Entry-side only; an Exit never commits and leaves it at zero. [`SsaIndex`] is a `NonZero<u32>`,
559 /// which is what makes `0` an unambiguous "none" rather than a sentinel that has to be defended.
560 committed_ssa_watermark: std::sync::atomic::AtomicU32,
561 /// [`SessionSlot::returned_packets`] as it stood when this Session first committed.
562 ///
563 /// The successor gate counts service *since* that instant, not since the Session opened. Before
564 /// the first commitment the generator holds no polynomials, so the SURBs going out carry no
565 /// shares — and an Exit may legitimately be served up to `max_predeposit_packets` of them before
566 /// any deposit exists. Crediting that prefix would let the Exit bank unpaid service against the
567 /// first cycle it *is* paid for. Everything after this point rides a share.
568 returned_at_first_commit: std::sync::atomic::AtomicU64,
569 /// What this Exit sent as deposit data, per SSA index, for cycles whose deposit address it has
570 /// not learned yet.
571 sent_deposit_data: Arc<parking_lot::Mutex<HashMap<SsaIndex, HoprPixDepositPayload>>>,
572}
573
574impl SessionSsaState {
575 pub fn new(params: PixParams) -> Self {
576 Self {
577 params,
578 committed_ssa_watermark: std::sync::atomic::AtomicU32::new(0),
579 returned_at_first_commit: std::sync::atomic::AtomicU64::new(0),
580 sent_deposit_data: Default::default(),
581 }
582 }
583
584 /// Files the deposit data of a batch the Exit is about to send, for
585 /// [`take_sent_deposit_data`](Self::take_sent_deposit_data) to hand back once the matching
586 /// deposit address is known.
587 pub fn remember_sent_deposit_data(&self, deposit_data: &HashMap<SsaIndex, HoprPixDepositPayload>) {
588 let mut sent = self.sent_deposit_data.lock();
589 for (&ssa_index, payload) in deposit_data {
590 sent.insert(ssa_index, payload.clone());
591 }
592 }
593
594 /// Removes and returns what this Exit sent for `ssa_index`, or empty data if it sent none.
595 pub fn take_sent_deposit_data(&self, session_id: &SessionId, ssa_index: SsaIndex) -> PixDepositData {
596 PixDepositData {
597 id: PixAddressId::new(session_id, ssa_index),
598 data: self
599 .sent_deposit_data
600 .lock()
601 .remove(&ssa_index)
602 .map(|payload| payload.0)
603 .unwrap_or_default(),
604 }
605 }
606
607 /// Highest SSA index committed for this Session, `None` before the first batch.
608 #[inline]
609 pub fn committed_watermark(&self) -> Option<SsaIndex> {
610 SsaIndex::new(self.committed_ssa_watermark.load(Ordering::Relaxed))
611 }
612
613 /// Raises the committed-index watermark to `index` if it is higher, taking the returned-packet
614 /// baseline on the first commitment.
615 ///
616 /// `fetch_max` rather than a store: the gate serialises requests per pseudonym, but a Session's
617 /// slot is shared and monotonicity here must not depend on that lock staying where it is. Its
618 /// *previous* value is also what makes the baseline race-free without leaning on that lock —
619 /// exactly one caller can observe a prior watermark of zero, so exactly one stores.
620 #[inline]
621 pub fn note_committed(&self, index: SsaIndex, returned_packets: u64) {
622 if self.committed_ssa_watermark.fetch_max(index.get(), Ordering::Relaxed) == 0 {
623 self.returned_at_first_commit.store(returned_packets, Ordering::Relaxed);
624 }
625 }
626
627 /// Exit → Entry packets received since this Session's first commitment.
628 ///
629 /// Saturating rather than wrapping: `returned_packets` is monotonic and the baseline is a past
630 /// value of it, so an underflow is impossible — and if one ever became possible, reporting zero
631 /// service closes the successor gate rather than opening it.
632 #[inline]
633 pub fn served_since_first_commit(&self, returned_packets: u64) -> u64 {
634 returned_packets.saturating_sub(self.returned_at_first_commit.load(Ordering::Relaxed))
635 }
636
637 /// Data quota one SSA cycle of these dimensions covers.
638 ///
639 /// The whole cycle, surplus included, as per [`pix_params_to_quota`]: every share a cycle emits
640 /// rides one Exit → Entry packet, so all of them are priced.
641 #[inline]
642 pub const fn quota_per_ssa(&self) -> SsaQuota {
643 pix_params_to_quota(&self.params)
644 }
645}
646
647#[derive(Clone)]
648pub(crate) struct SessionSlot {
649 // Sender does not need to be in Arc, because the receiver part is always
650 // wrapped inside DropAbortable wrapper, with abort handle added to `abort_handles`.
651 session_tx: crossfire::MTx<crossfire::mpsc::Array<ApplicationDataIn>>,
652 routing_opts: DestinationRouting,
653 // Additional tasks spawned by the Session.
654 abort_handles: Arc<parking_lot::Mutex<AbortableList<SessionHandles>>>,
655 // Allows reconfiguring of the SURB balancer on-the-fly
656 // Set on both Entry and Exit sides.
657 surb_mgmt: Arc<BalancerStateValues>,
658 // SURB flow updates happening outside of Session protocol
659 // (e.g., due to Start protocol messages).
660 surb_estimator: AtomicSurbFlowEstimator,
661 // Contains currently active SSA for this Session and its quota
662 current_ssa_state: Arc<OnceLock<SessionSsaState>>,
663 /// Handle to this Session's PIX supervisor, on the Exit side of a PIX-enabled Session.
664 ///
665 /// Populated before the [`HoprSession`] is constructed, so the egress adapters below observe a
666 /// gate rather than racing its installation. Empty on the Entry side and on non-PIX Sessions —
667 /// the Exit is authoritative for the lifecycle, so the Entry runs no supervisor.
668 pix_supervisor: Arc<OnceLock<SessionPixSupervisorHandle>>,
669 /// The egress gate every outgoing data packet of a supervised Session must pass.
670 ///
671 /// Held separately from `pix_supervisor` because the egress path touches it per packet and has
672 /// no use for the rest of the handle.
673 pix_egress_gate: Arc<OnceLock<Arc<ServiceGate>>>,
674 /// Steers the Exit's own Exit → Entry keep-alive stream, where the Session has one.
675 ///
676 /// Exit-side, and populated for every PIX Session as well as for any Session with a configured
677 /// `surb_balance_notify_period` — the two producers share one stream, so they share one handle.
678 /// Empty on the Entry side, whose keep-alives are driven by its SURB balancer instead, and on an
679 /// Exit Session that is neither PIX nor notifying, which originates nothing at all.
680 ///
681 /// A `OnceLock` for the same reason the two fields above are: the action driver that sets rates
682 /// on it is spawned after the slot is published, so it must find the handle rather than race its
683 /// installation.
684 pix_fill: Arc<OnceLock<Arc<PixFillControl>>>,
685 /// Exit → Entry Session packets received on this Session, ever.
686 ///
687 /// Entry-side only; stays zero on the Exit, which is the side that *sends* them. This includes
688 /// both Session data and Start-protocol traffic such as the Exit's SURB-level keep-alives. Each
689 /// packet consumed one return SURB, and a SURB carries at most one PIX share which the Exit can
690 /// only decrypt by using it — so this counts shares the Exit has unlocked, measured without
691 /// asking it. That is what makes it usable as a deposit gate: see the successor gate in
692 /// [`SessionManager::handle_ssa_request`].
693 ///
694 /// Deliberately *not* [`surb_estimator`](Self::surb_estimator)`.consumed`, which increments on
695 /// the same event on this side today. That field is documented as an *estimate* feeding the PID
696 /// balancer, and it is only wired when `surb_management` is enabled; this one decides whether
697 /// money is spent and must be neither. Carrying the increment twice is the cheaper mistake: a
698 /// receive path that forgets this counter makes the gate stricter, never laxer.
699 ///
700 /// Credited from exactly two places, and both of them have to stay:
701 ///
702 /// * the `session_rx` inspectors in `new_session`, once per Session data packet handed to the reader — one in each
703 /// of the balanced and unbalanced branches;
704 /// * the Start-protocol branch of [`dispatch_message`](SessionManager::dispatch_message), which credits every
705 /// Start-protocol message arriving on an outgoing Session because that traffic bypasses `session_rx` entirely.
706 ///
707 /// The second is what makes the Exit's keep-alives count, and the gate depends on it: an Exit
708 /// whose application has nothing to say can still complete a funded cycle by keep-alive alone, and
709 /// if those packets earned nothing here its successor would be refused as under-served and the
710 /// Session would die on `CommitmentTimeout` with neither side able to name the cause. It is
711 /// credited at the dispatch level rather than inside a per-message handler on purpose — the credit
712 /// is owed for the SURB the packet consumed, which is true whatever the message turns out to be,
713 /// including one this build fails to parse. Adding a second increment inside
714 /// [`handle_keep_alive`](SessionManager::handle_keep_alive) would double-count it.
715 returned_packets: Arc<std::sync::atomic::AtomicU64>,
716 /// This Session's share of the node's live reconstructor-cycle budget.
717 ///
718 /// Exit-side and PIX-only; `None` everywhere else, since nothing else holds cycle state. Behind
719 /// an `Arc` so the budget is returned when the last clone of this slot goes: a slot is cloned
720 /// into the Session cache and into its [`SessionSlotGuard`], so no single one of them can be
721 /// made responsible for the release.
722 ///
723 /// [`close_session`] returns it explicitly, which is what makes the release simultaneous with
724 /// the closure; the `Drop` behind the `Arc` is the backstop for anything that bypasses that
725 /// function. Both are safe to run because the release is idempotent.
726 cycle_budget: Option<Arc<CycleBudgetReservation>>,
727}
728
729/// The Exit's control over the single Exit → Entry keep-alive stream a Session runs.
730///
731/// One stream, two producers with different jobs, and this is what reconciles them:
732///
733/// * the **SURB-level notification**, sent every `surb_balance_notify_period`, which tells the Entry's balancer how
734/// many SURBs the Exit believes it holds. It is the signal that asks for more, so it must go out even when there are
735/// none left;
736/// * **PIX fill**, whose rate the supervisor plans once a second so that a funded cycle completes inside its own
737/// deadline even if the application never sends a byte.
738///
739/// The effective rate is the faster of the two, because both are the same packet: a keep-alive
740/// carrying the SURB level is exactly what fill needs to send, and sending two streams of them would
741/// double the traffic to say the same thing twice. Every shipped Entry already parses it, counts it
742/// as a consumed SURB so its balancer refills, and — since the fix that precedes this — credits it as
743/// service against the successor gate.
744pub(crate) struct PixFillControl {
745 session_id: SessionId,
746 /// The rate limiter of the keep-alive stream this steers.
747 controller: RateController,
748 /// The configured SURB-level notification period, if the node has one.
749 ///
750 /// `None` disables the notification half entirely; the stream then runs at the fill rate alone,
751 /// which is how a PIX Session on a node with `surb_balance_notify_period: None` still fills.
752 notify_period: Option<Duration>,
753 /// SURBs that must remain estimated-available before a *fill* packet is let through.
754 ///
755 /// The *effective* reserve for this Session, not the configured one: the Entry decides how deep
756 /// the Exit's SURB buffer for the Session is, so a reserve above what the Entry ever intends to
757 /// supply would refuse every fill packet forever. See [`effective_surb_reserve`].
758 min_surb_reserve: u64,
759 /// The Session's SURB flow estimate, which is what the reserve is measured against.
760 estimator: AtomicSurbFlowEstimator,
761 state: parking_lot::Mutex<PixFillState>,
762}
763
764struct PixFillState {
765 /// Whether the initial notification delay has elapsed.
766 ///
767 /// The notification stream deliberately waits one period before starting, so a Session that
768 /// closes immediately never sends one. Fill does not wait, and must not be held behind this.
769 notify_started: bool,
770 /// The rate the PIX supervisor last asked for. [`FillRate::ZERO`] when it is not filling.
771 fill: FillRate,
772 /// When a packet was last let through as the SURB-level notification.
773 ///
774 /// The two producers share a stream, so nothing about a packet says which of them it belongs to.
775 /// This is what makes the distinction: one packet per notification period is the notification and
776 /// is exempt from the SURB reserve, and everything above that rate is fill and is not.
777 last_notify_at: Option<Instant>,
778 /// Whether the SURB reserve is currently withholding fill, so `hopr_pix_sessions_stalled` counts
779 /// this Session once per stall rather than once per refused packet.
780 withholding: bool,
781 /// Whether this stream is draining the SURB buffer of a Session that has already been closed.
782 ///
783 /// Set once, by [`PixFillControl::enter_drain`], and never cleared: a drain ends with the
784 /// Session, and the stream with it.
785 draining: bool,
786}
787
788impl PixFillControl {
789 fn new(
790 session_id: SessionId,
791 controller: RateController,
792 notify_period: Option<Duration>,
793 min_surb_reserve: u64,
794 estimator: AtomicSurbFlowEstimator,
795 ) -> Self {
796 Self {
797 session_id,
798 controller,
799 notify_period,
800 // Clamped rather than trusted: a `SessionManager` can be built from a config that never
801 // went through `validate_pix_supervision`, and a zero reserve would let fill spend the
802 // Session's last SURB. `effective_surb_reserve` applies the same floor, so this is only
803 // load-bearing for the tests that construct a control directly.
804 min_surb_reserve: min_surb_reserve.max(1),
805 estimator,
806 state: parking_lot::Mutex::new(PixFillState {
807 notify_started: false,
808 fill: FillRate::ZERO,
809 last_notify_at: None,
810 withholding: false,
811 draining: false,
812 }),
813 }
814 }
815
816 /// Turns the SURB-level notification on, after its initial delay.
817 ///
818 /// A no-op once the Session is draining, for the reason [`enter_drain`](Self::enter_drain)
819 /// gives — and it has to be checked here rather than only there, because the one-shot delay task
820 /// holds a clone of this control and nothing bounds how long an operator may make its period.
821 pub(crate) fn start_notify(&self) {
822 let mut state = self.state.lock();
823 if state.draining {
824 return;
825 }
826 state.notify_started = true;
827 self.apply(&state);
828 }
829
830 /// Puts the stream into post-close drain mode, silencing the SURB-level notification.
831 ///
832 /// Returns `false` if the stream was draining already, which is how two closes racing through
833 /// `begin_surb_drain` settle which of them owns the drain. The read and the write have to be one
834 /// lock acquisition for that: callers that each saw [`is_draining`](Self::is_draining) before
835 /// either wrote it would both start a drain, and both tell the supervisor about it.
836 ///
837 /// The notification is the single packet [`admit_at`](Self::admit_at) exempts from the SURB
838 /// reserve, and the exemption exists for one reason: it is the message that asks the Entry for
839 /// more SURBs. It asks on behalf of a Session that a drain has already lost — the Entry itself
840 /// may well still be there, but nothing is left to act on the request — so leaving it on would
841 /// originate below the reserve into a buffer nothing is refilling, which is the one failure mode
842 /// [`PixFillConfig::min_surb_reserve`](crate::supervision::PixFillConfig::min_surb_reserve)
843 /// exists to prevent, since a return packet with no SURB holds up every packet this node
844 /// originates. With it off, `notification_due` is always false and the reserve is the only gate.
845 pub(crate) fn enter_drain(&self) -> bool {
846 let mut state = self.state.lock();
847 if state.draining {
848 return false;
849 }
850 state.draining = true;
851 state.notify_started = false;
852 self.apply(&state);
853 true
854 }
855
856 /// Whether this Session is draining its SURB buffer after having been closed.
857 pub(crate) fn is_draining(&self) -> bool {
858 self.state.lock().draining
859 }
860
861 /// The SURBs this Session could still spend on a drain, net of the reserve it must leave behind.
862 ///
863 /// An estimate and an upper bound at that — ring-buffer overwrites, store eviction and
864 /// invalidated relayers are all unobserved — which is why the supervisor weighs it against a
865 /// cycle's whole remaining emission rather than a discounted one. The reserve subtracted is the
866 /// *effective* one already derived for this Session, not the configured ceiling.
867 pub(crate) fn drainable_surbs(&self) -> u64 {
868 self.estimator.saturating_diff().saturating_sub(self.min_surb_reserve)
869 }
870
871 /// Applies a rate planned by the PIX supervisor.
872 pub(crate) fn set_fill(&self, rate: FillRate) {
873 let mut state = self.state.lock();
874 if state.fill == rate {
875 return;
876 }
877 state.fill = rate;
878 self.apply(&state);
879 }
880
881 /// The rate the stream is currently running at — the faster of the two producers.
882 ///
883 /// The production path never asks: [`apply`](Self::apply) derives it and hands it straight to the
884 /// controller, which is the only consumer that matters. This exists so the merge rule can be
885 /// tested as the rule it is, rather than inferred from packet timings.
886 #[cfg(test)]
887 pub(crate) fn effective_rate(&self) -> FillRate {
888 Self::effective(&self.state.lock(), self.notify_period)
889 }
890
891 fn effective(state: &PixFillState, notify_period: Option<Duration>) -> FillRate {
892 let notify = state
893 .notify_started
894 .then_some(notify_period)
895 .flatten()
896 .map(FillRate::once_per);
897 let fill = (!state.fill.is_zero()).then_some(state.fill);
898 match (notify, fill) {
899 (Some(notify), Some(fill)) if fill.as_packets_per_sec() > notify.as_packets_per_sec() => fill,
900 (Some(notify), _) => notify,
901 (None, Some(fill)) => fill,
902 (None, None) => FillRate::ZERO,
903 }
904 }
905
906 fn apply(&self, state: &PixFillState) {
907 let rate = Self::effective(state, self.notify_period);
908 // A zero period would trip the controller's own assertion. `FillRate` never carries one —
909 // `ZERO` is one second and the two constructors take a validated period — so this is a
910 // belt-and-braces floor rather than a case that arises.
911 self.controller
912 .set_rate_per_unit(rate.packets as usize, rate.per.max(Duration::from_micros(1)));
913 trace!(
914 session_id = %self.session_id,
915 packets = rate.packets,
916 per = ?rate.per,
917 "applied the exit keep-alive rate"
918 );
919 }
920
921 /// Decides whether the next keep-alive may go out, and books it if so.
922 ///
923 /// The SURB-level notification always may: it is the message that asks the Entry for more SURBs,
924 /// so refusing it when SURBs are scarce is refusing to ask for the thing that is scarce.
925 ///
926 /// Fill above that rate is refused while the estimate is below the reserve, and that refusal is
927 /// not an optimisation. A return packet that finds no SURB is not dropped — the routing resolver
928 /// holds it, and with it every packet this node originates, for the whole of
929 /// `surb_resolution_wait`. An Exit filling a cycle down to its last SURB would therefore stall
930 /// the Session it is filling for, and every other Session on the node with it.
931 ///
932 /// # Which packet is the notification
933 ///
934 /// Nothing about a packet says which producer it belongs to, so the notification is the first one
935 /// released no sooner than a period after the last — *less a slack of
936 /// `min(MAX_WAIT_CHUNK, period / 2)`*, which is what keeps the classification from resting on
937 /// scheduling latency. The rate limiter that releases these packets sleeps in chunks of
938 /// [`MAX_WAIT_CHUNK`], re-reads its controller on each wake, and rounds a rate into an integer
939 /// packet count, so a stream asked for one packet per period releases them a shade over or a
940 /// shade under it. Compared against the period exactly, a period the controller rounds short by a
941 /// microsecond would classify every other notification as fill — counted in
942 /// `hopr_session_pix_fill_packets_total` and, below the reserve, dropped. That is the signal
943 /// asking the Entry for the SURBs whose absence caused the drop, and it runs on every Exit
944 /// Session with a `surb_balance_notify_period` configured, PIX or not.
945 ///
946 /// The slack cannot let fill masquerade as the notification: `last_notify_at` advances to the
947 /// instant the packet actually went out, so only the *first* packet past the slack window is the
948 /// notification, and every fill packet that follows it inside the period is still fill and is
949 /// still bounded by the reserve. The cap at half the period is what stops a very short period
950 /// from having its window widened away to nothing.
951 pub(crate) fn admit(&self) -> bool {
952 self.admit_at(Instant::now())
953 }
954
955 /// [`admit`](Self::admit) against a caller-supplied instant, so the classification can be tested
956 /// as the rule it is rather than by sleeping out a period and hoping the scheduler cooperates.
957 fn admit_at(&self, now: Instant) -> bool {
958 let mut state = self.state.lock();
959
960 // Due *slightly early*: see the classification rule on `admit`. The slack is what keeps this
961 // from resting on the scheduling latency of the stream that releases the packet.
962 let notification_due = state.notify_started
963 && self.notify_period.is_some_and(|period| {
964 let slack = MAX_WAIT_CHUNK.min(period / 2);
965 state
966 .last_notify_at
967 .is_none_or(|at| now.saturating_duration_since(at) >= period.saturating_sub(slack))
968 });
969
970 if notification_due {
971 state.last_notify_at = Some(now);
972 return true;
973 }
974
975 if self.estimator.saturating_diff() >= self.min_surb_reserve {
976 Self::set_withholding(&mut state, false);
977 #[cfg(feature = "telemetry")]
978 telemetry::record_pix_fill_packet();
979 return true;
980 }
981
982 debug!(
983 session_id = %self.session_id,
984 reserve = self.min_surb_reserve,
985 estimate = self.estimator.saturating_diff(),
986 "withholding a PIX fill keep-alive to stay above the SURB reserve"
987 );
988 Self::set_withholding(&mut state, true);
989 #[cfg(feature = "telemetry")]
990 telemetry::pix::record_pix_fill_backoff(telemetry::pix::PixFillBackoff::SurbReserve);
991 false
992 }
993
994 /// Moves the `surb_starved` gauge on the edges only, so it counts stalled Sessions rather than
995 /// refused packets — the same distinction `PixGateBlockEpisode` draws for the egress gate.
996 fn set_withholding(state: &mut PixFillState, withholding: bool) {
997 if state.withholding == withholding {
998 return;
999 }
1000 state.withholding = withholding;
1001 crate::supervision::telemetry::emit_sessions_stalled(
1002 crate::supervision::telemetry::PixStallCause::SurbStarved,
1003 if withholding { 1 } else { -1 },
1004 );
1005 }
1006}
1007
1008/// Returns this Session's `surb_starved` count if it was withholding when it went away.
1009///
1010/// `Drop` rather than a call on the close paths, for the reason `PixGateBlockEpisode` gives: a
1011/// Session can end by teardown, by drain, or by its last `Arc` going out of scope in a task nobody
1012/// is waiting on, and this is the one mechanism that covers all three. Without it a Session that
1013/// dies mid-stall leaves the gauge standing for the life of the process.
1014impl Drop for PixFillControl {
1015 fn drop(&mut self) {
1016 Self::set_withholding(self.state.get_mut(), false);
1017 }
1018}
1019
1020/// The SURB reserve one Session's fill actually respects.
1021///
1022/// [`PixFillConfig::min_surb_reserve`](crate::supervision::PixFillConfig::min_surb_reserve) is a
1023/// *ceiling* rather than the value used, and it has to be, because the two sides of the comparison
1024/// are chosen by different nodes. The reserve is measured against the Exit's estimate of its own SURB
1025/// buffer for the Session, and how deep that buffer is is the *Entry's* decision — it announces its
1026/// balancer target in the lower 32 bits of the options word in `StartInitiation::additional_data`. An operator sizing
1027/// the reserve against a production buffer therefore also decides, unintentionally, that every Session
1028/// whose Entry asks for a shallower one can never be filled at all: the estimate never reaches the
1029/// reserve, every fill packet is withheld, and the funded cycle strands on `max_recovery_time` while
1030/// the logs report only a backoff.
1031///
1032/// A quarter of the announced target is what the reserve is for expressed as a proportion: it leaves
1033/// fill three quarters of whatever buffer the Entry chose and keeps the last quarter for the
1034/// application, which is the side that has something waiting on it. The configured value still binds
1035/// wherever it is the smaller of the two — at the shipped 500 against the worked profile's 7 000-SURB
1036/// target, a quarter is 1 750, so the derivation is inert on a production-sized Session and only
1037/// engages on one whose Entry asked for less than four times the configured reserve.
1038///
1039/// Floored at one, for the same reason the constructor clamps: a zero reserve is fill spending the
1040/// Session's last SURB, and an Entry announcing a three-SURB buffer would otherwise produce exactly
1041/// that.
1042fn effective_surb_reserve(configured: u64, announced_target: u64) -> u64 {
1043 configured.min(announced_target / 4).max(1)
1044}
1045
1046/// One Session's reservation against the node's live reconstructor-cycle budget.
1047///
1048/// Charged when a PIX Session is accepted and returned when its Session closes. Deliberately a
1049/// projection rather than a measurement: the alternative is to weigh the reconstructor's actual
1050/// state and refuse a share once it is too large, which loses the cycle of whichever Session
1051/// happened to arrive last rather than of the one that inflated it. A reservation taken up front can
1052/// only ever refuse a Session that does not exist yet.
1053#[derive(Debug)]
1054pub(crate) struct CycleBudgetReservation {
1055 bytes: u64,
1056 outstanding: Arc<std::sync::atomic::AtomicU64>,
1057 /// Whether [`release`](Self::release) has already run, so it can be called from both the close
1058 /// path and `Drop` without the budget being returned twice.
1059 released: std::sync::atomic::AtomicBool,
1060}
1061
1062impl CycleBudgetReservation {
1063 /// Returns the reservation to the node's budget. Idempotent.
1064 ///
1065 /// Called explicitly by [`close_session`], and by `Drop` as the backstop.
1066 ///
1067 /// Both, and not just `Drop`, because the slot lives in a `moka` cache: `remove` hands the value
1068 /// back but drops the cache's own clone during a later maintenance pass, so a purely
1069 /// refcount-driven release would return the budget at an unpredictable time — and a node whose
1070 /// Sessions all closed could still refuse the next one. The explicit call makes the release
1071 /// simultaneous with the closure that caused it; the flag is what keeps the deferred drop from
1072 /// crediting it a second time.
1073 fn release(&self) {
1074 if self.released.swap(true, Ordering::Relaxed) {
1075 return;
1076 }
1077 // Saturating, so a release the flag somehow failed to suppress could not wrap the counter
1078 // into a budget that admits everything.
1079 let outstanding = self
1080 .outstanding
1081 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |held| {
1082 Some(held.saturating_sub(self.bytes))
1083 })
1084 .unwrap_or_default()
1085 .saturating_sub(self.bytes);
1086
1087 #[cfg(feature = "telemetry")]
1088 crate::telemetry::pix::record_cycle_bytes_released(self.bytes);
1089
1090 trace!(
1091 released = self.bytes,
1092 outstanding, "released live-cycle budget reservation"
1093 );
1094 }
1095}
1096
1097impl Drop for CycleBudgetReservation {
1098 fn drop(&mut self) {
1099 self.release();
1100 }
1101}
1102
1103/// RAII guard that rolls back a freshly inserted [`SessionSlot`] unless the
1104/// session setup is explicitly [committed](SessionSlotGuard::commit).
1105///
1106/// Establishing a session involves several fallible steps *after* the slot has
1107/// been inserted into the Session cache (constructing the [`HoprSession`],
1108/// notifying about the new session, sending the establishment message, ...).
1109/// If any of these steps fails, the already inserted slot would otherwise linger
1110/// in the cache until idle eviction, blocking the pseudonym (and counting towards
1111/// `maximum_sessions`) in the meantime.
1112///
1113/// Dropping this guard without committing removes the slot and tears down the
1114/// partially initialized session. Since Moka's removal is asynchronous and Rust
1115/// has no asynchronous `Drop`, the cleanup is performed on a spawned task.
1116struct SessionSlotGuard<'a> {
1117 sessions: &'a moka::sync::Cache<SessionId, SessionSlot>,
1118 active_sessions: Arc<std::sync::atomic::AtomicUsize>,
1119 session_id: SessionId,
1120 committed: bool,
1121}
1122
1123impl<'a> SessionSlotGuard<'a> {
1124 fn new(
1125 sessions: &'a moka::sync::Cache<SessionId, SessionSlot>,
1126 session_id: SessionId,
1127 active_sessions: Arc<std::sync::atomic::AtomicUsize>,
1128 ) -> Self {
1129 Self {
1130 sessions,
1131 active_sessions,
1132 session_id,
1133 committed: false,
1134 }
1135 }
1136
1137 /// Marks the session as successfully established, preventing the slot from
1138 /// being rolled back when this guard is dropped.
1139 ///
1140 /// Logs "new session established" here, on both the Entry and the Exit side, so that every
1141 /// "session closed" line from [`close_session`] has a matching start line.
1142 fn commit(&mut self) {
1143 self.committed = true;
1144 tracing::info!(session_id = %self.session_id, "new session established");
1145
1146 #[cfg(all(feature = "telemetry", not(test)))]
1147 METRIC_ACTIVE_SESSIONS.increment(1.0);
1148 }
1149}
1150
1151impl Drop for SessionSlotGuard<'_> {
1152 fn drop(&mut self) {
1153 if !self.committed {
1154 // The session setup failed after the slot was inserted: remove it so it does
1155 // not block the pseudonym until idle eviction.
1156 let session_id = self.session_id;
1157 warn!(%session_id, "rolling back partially established session slot after setup failure");
1158 if let Some(slot) = self.sessions.remove(&session_id) {
1159 self.active_sessions.fetch_sub(1, Ordering::Relaxed);
1160 // Not `close_session`: the Session was never established, so there is no
1161 // "session closed" line to pair with "new session established".
1162 release_session_slot(session_id, slot, ClosureReason::Eviction);
1163 }
1164 }
1165 }
1166}
1167
1168/// Why an inbound session data packet was dropped without being delivered.
1169///
1170/// All three are ordinary consequences of a session tearing down or being briefly overwhelmed, not
1171/// faults: a departing counterparty leaves packets in flight that arrive after its slot's sink is
1172/// gone or after the slot itself is deregistered. They are reported as a [`DispatchResult::Dropped`]
1173/// outcome rather than an `Err` precisely so the caller does not log them at `ERROR` — the loud
1174/// teardown-race spam this distinction exists to remove.
1175#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1176pub enum DropReason {
1177 /// The slot is still registered but its data sink (receiver) has been dropped
1178 /// (`TrySendError::Disconnected`). The common case when a session is being torn down.
1179 SinkClosed,
1180 /// The slot's inbox is full (`TrySendError::Full`): genuine backpressure, the reader is not
1181 /// keeping up. Distinct from [`SinkClosed`](Self::SinkClosed) because it is transient, not a
1182 /// teardown.
1183 SinkFull,
1184 /// No slot for this session id is registered (formerly the `UnknownData` error): the session
1185 /// was fully deregistered before this packet arrived.
1186 Unregistered,
1187}
1188
1189/// Indicates the result of processing a message.
1190#[derive(Clone, Debug, PartialEq, Eq)]
1191pub enum DispatchResult {
1192 /// Session or Start protocol message has been processed successfully.
1193 Processed,
1194 /// The message was not related to Start or Session protocol.
1195 Unrelated(ApplicationDataIn),
1196 /// The message belonged to a session but could not be delivered and was dropped. Benign — see
1197 /// [`DropReason`]. Reported as `Ok` so the caller logs it quietly instead of as an `ERROR`.
1198 Dropped(DropReason),
1199}
1200
1201/// Configuration of the PIX protocol for incoming Sessions on Exit nodes.
1202#[derive(Clone, Debug, PartialEq, smart_default::SmartDefault, serde::Serialize, serde::Deserialize)]
1203#[serde(default, deny_unknown_fields)]
1204pub struct IncomingSessionPixConfig {
1205 /// If set to true, incoming Session without the [`Capability::UsePIX`] will be rejected.
1206 ///
1207 /// Default `false`.
1208 #[default(false)]
1209 pub enforce_pix: bool,
1210 /// Acceptable range of data quota in bytes.
1211 ///
1212 /// With dynamic SSA batches enabled, the Exit selects the smallest batch up to
1213 /// [`SupervisorConfig::ssas_per_request`] whose total `batch_size × quota_per_ssa` is inside this
1214 /// range. With dynamic batches disabled, the Entry's per-SSA quota itself must be inside the
1215 /// range and the configured batch size is used exactly. An offer that satisfies neither rule is
1216 /// rejected before the Session is established.
1217 ///
1218 /// The default is derived from the default PIX dimensions
1219 /// ([`crate::DEFAULT_PIX_POLYS_PER_SSA`] × ([`crate::DEFAULT_PIX_SHARES_PER_POLY`] +
1220 /// [`crate::DEFAULT_PIX_SURPLUS_SHARES`])) rather than hard-coded, so that an Entry running the
1221 /// default configuration is always accepted. The upper bound is exactly
1222 /// [`DEFAULT_PIX_SSA_QUOTA`]. Since every dynamic batch has at least one member, that upper bound
1223 /// still caps the data served by any one SSA cycle. Accepting larger dimensions would raise both
1224 /// that exposure and the reconstructor memory held per Session, so an Exit that wants to serve
1225 /// them must widen this range explicitly.
1226 ///
1227 /// The quota it is compared against counts the surplus — `polys × (threshold + surplus) ×
1228 /// PIX_QUOTA_BYTES_PER_SHARE` — so this bounds the traffic actually served rather than the
1229 /// fraction of it the threshold accounts for. It used to bound only the latter, which understated
1230 /// the exposure by the surplus factor: 1.25× at the deployed dimensions.
1231 ///
1232 /// Defaults to `DEFAULT_PIX_SSA_QUOTA / 4 ..= DEFAULT_PIX_SSA_QUOTA`
1233 /// (≈ 227 MiB to ≈ 908 MiB, inclusive).
1234 #[default(_code = "DEFAULT_PIX_SSA_QUOTA / DEFAULT_PIX_QUOTA_RANGE_SPAN..=DEFAULT_PIX_SSA_QUOTA")]
1235 pub quota_range: std::ops::RangeInclusive<u64>,
1236 /// Ceiling on the live Exit-side reconstructor state this node will commit to, in bytes.
1237 ///
1238 /// **This, not [`SessionManagerConfig::maximum_sessions`], is what bounds reconstructor
1239 /// memory.** A PIX Session reserves `MAX_OVERLAPPING_BATCHES × ssas_per_request ×
1240 /// hopr_protocol_pix::peak_cycle_bytes(offered params)` when it is accepted and holds it until
1241 /// it closes; a Session that does not fit is refused with
1242 /// [`StartErrorReason::NoSlotsAvailable`] before any state is allocated for it. The reservation
1243 /// is computed from the parameters the *peer* offered, so a Session asking for smaller
1244 /// dimensions costs proportionally less of the budget.
1245 ///
1246 /// Enforced at admission rather than validated as a product of the configuration, for the reason
1247 /// `SsaReconstructorConfig::max_ack_buffer_bytes` gives: `maximum_sessions` validates to
1248 /// 100 000, and the resulting product is a number no node could hold, so validating it would
1249 /// only ever reject the shipping defaults. Counting what has actually been committed to is
1250 /// indifferent to how the ceiling was configured.
1251 ///
1252 /// Default is 3 GiB. Derived from the deployed operating point rather than picked: at the
1253 /// default dimensions a Session reserves ≈82 MiB, so this admits ≈37 concurrent PIX Sessions,
1254 /// comfortably covering the 10–30 clients per Exit the calibration profile models. The same
1255 /// defaults with `maximum_sessions = 100` and no budget imply ≈8 GiB.
1256 ///
1257 /// A ceiling on *reservations*, not an allocation: nothing is claimed up front, and because the
1258 /// reservation is denominated at the adversarial peak, a node serving conforming peers holds an
1259 /// order of magnitude less than it has reserved. The number exists to stop an Exit selling more
1260 /// service than its memory can hold, not to describe what it will typically use.
1261 #[default(3 * 1024 * 1024 * 1024)]
1262 pub max_live_cycle_bytes: u64,
1263 /// Deadlines, fault limits and service budget the Exit-side PIX supervisor enforces on a
1264 /// Session.
1265 ///
1266 /// These live together rather than spread across this struct because they are only meaningful
1267 /// as a set: [`crate::validate_pix_supervision`] checks them against each other and against the
1268 /// reconstructor's lifetimes, and the node's config validator runs that at load time.
1269 pub supervision: SupervisorConfig,
1270}
1271
1272impl IncomingSessionPixConfig {
1273 /// The terms one Session is judged on, once the session server has had its say.
1274 ///
1275 /// The configured [`quota_range`](Self::quota_range) is an envelope, not a default: it is what
1276 /// [`validate_incoming_session_pix_config`] checked the deadlines and memory ceiling against,
1277 /// and what [`start_protocol_channel_capacity`] sized a preallocated ring from, both once at
1278 /// startup. So a decision's range is *intersected* with it rather than replacing it — a Session
1279 /// may be held to less than the node advertises, never to more than it was configured to
1280 /// honour. An operator wanting a wider class configures a wider envelope and narrows the rest.
1281 ///
1282 /// [`enforce_pix`](Self::enforce_pix) carries no such coupling and is simply overridden.
1283 fn effective_for(&self, decision: &SessionAdmissionDecision) -> EffectivePixAdmission {
1284 let quota_range = match &decision.pix_quota_range {
1285 Some(narrowed) => {
1286 *self.quota_range.start().max(narrowed.start())..=*self.quota_range.end().min(narrowed.end())
1287 }
1288 None => self.quota_range.clone(),
1289 };
1290
1291 EffectivePixAdmission {
1292 enforce_pix: decision.enforce_pix.unwrap_or(self.enforce_pix),
1293 quota_range,
1294 allow_dynamic_ssa_batches: self.supervision.allow_dynamic_ssa_batches,
1295 ssas_per_request: self.supervision.ssas_per_request,
1296 }
1297 }
1298
1299 /// The supervisor configuration for Sessions accepted under these settings.
1300 pub fn supervisor_config(&self) -> SupervisorConfig {
1301 self.supervision.clone()
1302 }
1303}
1304
1305/// The PIX terms a single incoming Session is admitted on.
1306///
1307/// Lighter than the whole [`IncomingSessionPixConfig`] because only these four fields vary per
1308/// Session: everything else the supervisor enforces is validated as a set at startup and stays
1309/// node-wide. Narrowing the quota only makes those deadlines more generous, which is why they need
1310/// not travel with it.
1311#[derive(Clone, Debug, PartialEq)]
1312pub(crate) struct EffectivePixAdmission {
1313 /// Whether this Session must offer PIX at all.
1314 enforce_pix: bool,
1315 /// Quota window this Session's offer must land in, already intersected with the node's envelope.
1316 quota_range: std::ops::RangeInclusive<SsaQuota>,
1317 /// Whether a batch may be derived from the offer, rather than taken as configured.
1318 allow_dynamic_ssa_batches: bool,
1319 /// Largest batch the Exit will ask for.
1320 ssas_per_request: usize,
1321}
1322
1323impl EffectivePixAdmission {
1324 /// Selects the batch size with which an Entry's per-SSA quota is acceptable.
1325 ///
1326 /// Dynamic mode walks from one upwards so it asks for no more Entry work, deposits or Exit
1327 /// reconstructor state than necessary. Fixed mode is the legacy rule: validate one SSA against
1328 /// the range, then request exactly the configured batch size.
1329 fn ssa_batch_size_for_quota(&self, quota_per_ssa: SsaQuota) -> Option<usize> {
1330 let configured_batch = self.ssas_per_request.clamp(1, MAX_SSA_BATCH_SIZE);
1331
1332 if !self.allow_dynamic_ssa_batches {
1333 return self.quota_range.contains("a_per_ssa).then_some(configured_batch);
1334 }
1335
1336 (1..=configured_batch).find(|batch_size| {
1337 quota_per_ssa
1338 .checked_mul(*batch_size as u64)
1339 .is_some_and(|batch_quota| self.quota_range.contains(&batch_quota))
1340 })
1341 }
1342}
1343
1344/// Bit position of the 64-bit options word within `StartInitiation::additional_data`.
1345///
1346/// The options word (PIX parameters and SURB balancer target) occupies the most significant 64 bits;
1347/// the least significant 64 bits are currently unused and always sent as `0`.
1348const INITIATION_OPTIONS_SHIFT: u32 = u64::BITS;
1349
1350/// Places the 64-bit options word into the most significant half of `StartInitiation::additional_data`,
1351/// leaving the (currently unused) least significant half zero.
1352#[inline]
1353const fn into_initiation_additional_data(options: u64) -> u128 {
1354 (options as u128) << INITIATION_OPTIONS_SHIFT
1355}
1356
1357/// Inverse of [`into_initiation_additional_data`], ignoring the least significant half.
1358#[inline]
1359const fn initiation_options(additional_data: u128) -> u64 {
1360 (additional_data >> INITIATION_OPTIONS_SHIFT) as u64
1361}
1362
1363/// Decodes the incentivization the Entry offered, before anyone is asked what to do about it.
1364///
1365/// `Ok(None)` is a peer that offered none. `Err` is one whose offer this node cannot read or
1366/// cannot share a curve with, which is refused on its own — no policy could rescue it, and both
1367/// questions are cheaper than the round trip to the session server that follows.
1368///
1369/// Split out from `SessionManager::check_pix_params` so the session server can be
1370/// told what was offered: it decides the terms, and terms relative to an offer need the offer.
1371fn decode_pix_offer(
1372 req: &StartInitiation<SessionTarget, HoprSessionCapabilities>,
1373) -> Result<Option<PixParams>, StartErrorReason> {
1374 if !req.capabilities.0.contains(Capability::UsePIX) {
1375 return Ok(None);
1376 }
1377
1378 // Unpacking is what enforces the protocol ranges on the three dimensions, and what rejects a
1379 // suite identifier no curve claims — leaving only "a known curve, but not ours" below.
1380 let params = PixParams::try_from_additional_data(initiation_options(req.additional_data)).map_err(|error| {
1381 debug!(
1382 challenge = req.challenge,
1383 %error,
1384 "client offered PIX parameters outside the protocol ranges"
1385 );
1386 StartErrorReason::UnacceptablePixParams
1387 })?;
1388
1389 // The Exit decides the curve, and it decides it by refusing anything else: nothing here
1390 // is negotiated, because there is nothing to negotiate — the suite is fixed at build
1391 // time on both sides. Checked before the quota because it is the cheaper question and
1392 // because a suite mismatch makes the dimensions meaningless anyway, and checked at all
1393 // because every later PIX field is sized by it. Refusing here means the Exit's own
1394 // commitments, the first curve-sized bytes in the exchange, are never sent to a peer
1395 // that would read their boundaries in the wrong place.
1396 if params.suite() != LOCAL_PIX_SUITE {
1397 warn!(
1398 challenge = req.challenge,
1399 offered = %params.suite(),
1400 ours = %LOCAL_PIX_SUITE,
1401 "refusing a client offering a PIX curve suite this node was not built for"
1402 );
1403 return Err(StartErrorReason::UnacceptablePixParams);
1404 }
1405
1406 Ok(Some(params))
1407}
1408
1409/// Validates the incoming-PIX invariants that span quota, supervision and memory settings.
1410///
1411/// `max_session_packet_rate` is the fastest a Session is assumed to go. Without fill it bounds
1412/// `max_recovery_time` directly; with fill it caps the `fill.max_rate` the deadline may count on.
1413pub fn validate_incoming_session_pix_config(
1414 cfg: &IncomingSessionPixConfig,
1415 max_session_packet_rate: u64,
1416) -> errors::Result<()> {
1417 if cfg.quota_range.is_empty() {
1418 return Err(TransportSessionError::InvalidConfig(
1419 "PIX quota_range must be non-empty (start must not exceed end)".into(),
1420 ));
1421 }
1422 if max_session_packet_rate == 0 {
1423 return Err(TransportSessionError::InvalidConfig(
1424 "maximum assumed PIX Session packet rate must be non-zero".into(),
1425 ));
1426 }
1427 // Named here although `validate_pix_supervision` also refuses it, because that runs later on both
1428 // paths: with fill enabled a zero deadline would otherwise surface as an infinite fill rate.
1429 if cfg.supervision.max_recovery_time.is_zero() {
1430 return Err(TransportSessionError::InvalidConfig(
1431 "PIX max_recovery_time must be non-zero".into(),
1432 ));
1433 }
1434
1435 let worst_cycle_packets = *cfg.quota_range.end() / PIX_QUOTA_BYTES_PER_SHARE;
1436
1437 // `max_recovery_time` has to leave a cycle of the widest accepted quota time to complete, and what
1438 // completes it depends on fill. With fill enabled the Exit finishes an under-served cycle itself, so
1439 // the bound is the fill ceiling's own arithmetic below. Without it only application traffic does,
1440 // and the Exit has no rate of its own to reason from.
1441 if cfg.supervision.fill.enabled {
1442 // The fill ceiling has to clear the rate a cycle of the widest accepted quota actually needs,
1443 // or the mechanism that exists to complete idle cycles cannot complete the very ones it
1444 // admits. The arithmetic is the planner's own, taken at the moment fill starts: the whole
1445 // cycle remains, the aim point sits `finish_fraction` of the way through `max_recovery_time`,
1446 // and the loss margin is paid on top. `max_recovery_time` is the *configured* per-cycle budget
1447 // rather than a deadline observed at run time, so this is a property of the configuration and
1448 // can be decided here.
1449 //
1450 // The ceiling counts only up to `max_session_packet_rate`: fill faster than any Session is
1451 // assumed to carry cannot finish a cycle any sooner.
1452 let fill = &cfg.supervision.fill;
1453 // Before the arithmetic, not after it. `validate_pix_supervision` also range-checks these two,
1454 // but it runs later on both paths that reach here — `SessionManager::start` calls this first,
1455 // and in `HoprProtocolConfig` this is a field validator while that is a schema-level one. The
1456 // `mul_f64` below panics on a `NaN` or negative fraction, so an unchecked one aborts the node
1457 // at config load rather than being reported; a fraction above one silently lowers the floor.
1458 crate::supervision::validate_fill_fractions(fill)?;
1459
1460 let horizon = cfg.supervision.max_recovery_time.mul_f64(fill.finish_fraction);
1461 // Still guarded although both factors are non-zero: a nanosecond-scale deadline times a
1462 // fraction rounds to zero, and dividing by it would report an infinite requirement.
1463 let floor = if horizon.is_zero() {
1464 u64::MAX
1465 } else {
1466 (worst_cycle_packets as f64 * (1.0 + fill.loss_margin) / horizon.as_secs_f64()).ceil() as u64
1467 };
1468 if (fill.max_rate as u64).min(max_session_packet_rate) < floor {
1469 let remedy = if floor > max_session_packet_rate {
1470 "raise max_recovery_time"
1471 } else {
1472 "raise fill.max_rate or max_recovery_time"
1473 };
1474 return Err(TransportSessionError::InvalidConfig(format!(
1475 "PIX fill.max_rate is {} (counted up to {max_session_packet_rate} packets/s), but the largest \
1476 accepted quota ({} bytes) is {worst_cycle_packets} packets and needs at least {floor} packets/s to \
1477 finish inside {horizon:?} with a {} loss margin; {remedy}",
1478 fill.max_rate,
1479 cfg.quota_range.end(),
1480 fill.loss_margin,
1481 )));
1482 }
1483 } else {
1484 // Refused only if even a Session at the fastest assumed rate could not finish a cycle in time,
1485 // so this rejects a deadline that is impossible rather than one that is merely tight.
1486 let needed = Duration::from_secs(worst_cycle_packets.div_ceil(max_session_packet_rate));
1487 if cfg.supervision.max_recovery_time < needed {
1488 return Err(TransportSessionError::InvalidConfig(format!(
1489 "PIX max_recovery_time is {:?}, but with fill disabled the largest accepted quota ({} bytes) is \
1490 {worst_cycle_packets} packets and needs at least {needed:?} even at {max_session_packet_rate} \
1491 packets/s",
1492 cfg.supervision.max_recovery_time,
1493 cfg.quota_range.end(),
1494 )));
1495 }
1496 }
1497
1498 let widest = max_cycle_budget_for_quota(*cfg.quota_range.end(), cfg.supervision.ssas_per_request);
1499 if cfg.max_live_cycle_bytes < widest {
1500 return Err(TransportSessionError::InvalidConfig(format!(
1501 "PIX max_live_cycle_bytes is {}, but one Session offering the largest accepted quota ({} bytes) reserves \
1502 up to {widest} bytes of reconstructor state",
1503 cfg.max_live_cycle_bytes,
1504 cfg.quota_range.end(),
1505 )));
1506 }
1507
1508 Ok(())
1509}
1510
1511/// Configuration for the [`SessionManager`].
1512#[derive(Clone, Debug, PartialEq, smart_default::SmartDefault)]
1513pub struct SessionManagerConfig {
1514 /// The maximum chunk of data that can be written to the Session's input buffer.
1515 ///
1516 /// Clamped to the socket's supported frame size range, preserving boundaries within that range.
1517 ///
1518 /// Default is [`SESSION_MTU`], i.e. exactly one segment per frame.
1519 #[default(SESSION_MTU)]
1520 pub frame_mtu: usize,
1521
1522 /// The maximum time for an incomplete frame to stay in the Session's output buffer.
1523 ///
1524 /// Must exceed the worst-case SURB replenishment latency on the return path.
1525 /// The SURB balancer replenishes via KeepAlive every ~2 s; the retry loop in
1526 /// the routing resolver sleeps 5 ms per attempt and can block for the full
1527 /// replenishment cycle. Setting this below the KeepAlive period causes the
1528 /// sequencer to discard frames that arrive after a SURB-starved peer finally
1529 /// gets new SURBs and sends its echo.
1530 ///
1531 /// Default is 3 s.
1532 #[default(Duration::from_secs(3))]
1533 pub max_frame_timeout: Duration,
1534
1535 /// Maximum number of segments to buffer in the downstream transport of a Session's socket.
1536 /// If 0 is given, the transport is unbuffered.
1537 ///
1538 /// Default is 0.
1539 #[default(0)]
1540 pub max_buffered_segments: usize,
1541
1542 /// Abandon the frame due next once this many later frames are waiting behind it, rather than
1543 /// holding them for the whole of [`Self::max_frame_timeout`].
1544 ///
1545 /// The two answer different questions. `max_frame_timeout` is how long a *missing* frame is
1546 /// waited for, and is set here to exceed the ~2 s SURB KeepAlive so a starved peer's late echo
1547 /// is not discarded. This bounds how much *already received* data is held hostage during that
1548 /// wait -- a cost paid once per gap, which compounds as later frames queue up.
1549 ///
1550 /// Without it, a session that cannot retransmit waits the full timeout for a frame that will
1551 /// never arrive. Measured on a 5-node cluster after killing a return relayer: 98.5 % of bytes
1552 /// returned over the wire, 0.60 % reached the application, and the application-side
1553 /// inter-arrival median sat exactly on the 3 s timeout.
1554 ///
1555 /// The right value tracks reordering depth -- throughput x latency spread / frame size -- so
1556 /// it is deployment-specific. Tune with `SessionConfig::max_frames_behind_gap`; too low converts
1557 /// ordinary reordering into loss, too high leaves the stall in place.
1558 ///
1559 /// Default is 256, which is roughly 3-4x the reordering depth of the cluster it was measured
1560 /// on (~0.5 MB/s over paths spread across 10-260 ms, 1500 B frames, so ~70-85 frames in
1561 /// flight out of order). The margin is not cosmetic: at 64 -- about one reordering depth --
1562 /// a *healthy* baseline dropped from 100 % to 92.2 % arrival, because ordinary reordering was
1563 /// being read as loss. At 256 the same baseline returned 100 % while a return relayer killed
1564 /// mid-session still recovered 97.1 %, against 0.60 % with the bound absent.
1565 ///
1566 /// Default is 256.
1567 #[default(Some(256))]
1568 pub max_frames_behind_gap: Option<usize>,
1569
1570 /// The base timeout for initiation of Session initiation.
1571 ///
1572 /// The actual timeout is adjusted according to the number of hops for that Session:
1573 /// `t = initiation_time_out_base * (num_forward_hops + num_return_hops + 2)`
1574 ///
1575 /// Default is 500 milliseconds.
1576 #[default(Duration::from_millis(500))]
1577 pub initiation_timeout_base: Duration,
1578
1579 /// Timeout for Session to be closed due to inactivity.
1580 ///
1581 /// Default is 180 seconds.
1582 #[default(Duration::from_secs(180))]
1583 pub idle_timeout: Duration,
1584
1585 /// Minimum interval at which an establishing Session's cache slot is refreshed
1586 /// ("touched") to keep [`idle_timeout`](Self::idle_timeout) from evicting it while
1587 /// SURBs pre-load.
1588 ///
1589 /// The touch period is [`idle_timeout`](Self::idle_timeout)` / 2`, floored to this
1590 /// value so it never drops below a sane minimum for very short idle timeouts.
1591 ///
1592 /// Default is 100 milliseconds.
1593 #[default(Duration::from_millis(100))]
1594 pub min_session_touch_period: Duration,
1595
1596 /// The sampling interval for SURB balancer.
1597 /// It will make SURB control decisions regularly at this interval.
1598 ///
1599 /// Default is 100 milliseconds.
1600 #[default(Duration::from_millis(100))]
1601 pub balancer_sampling_interval: Duration,
1602
1603 /// Initial packets per second egress rate on an incoming Session.
1604 ///
1605 /// This only applies to incoming Sessions without the [`Capability::NoRateControl`] flag set.
1606 ///
1607 /// Default is 10 packets/second.
1608 #[default(10)]
1609 pub initial_return_session_egress_rate: usize,
1610
1611 /// Minimum period of time for which a SURB buffer at the Exit must
1612 /// endure if no SURBs are being received.
1613 ///
1614 /// In other words, it is the minimum period of time an Exit must withstand when
1615 /// no SURBs are received from the Entry at all. To do so, the egress traffic
1616 /// will be shaped accordingly to meet this requirement.
1617 ///
1618 /// This only applies to incoming Sessions without the [`Capability::NoRateControl`] flag set.
1619 ///
1620 /// Default is 5 seconds, minimum is 1 second.
1621 #[default(Duration::from_secs(5))]
1622 pub minimum_surb_buffer_duration: Duration,
1623
1624 /// Indicates the maximum number of SURBs in the SURB buffer to be requested when creating a new Session.
1625 ///
1626 /// This value is theoretically capped by the size of the global transport SURB ring buffer,
1627 /// so values greater than that do not make sense. This value should be ideally set equal
1628 /// to the size of the global transport SURB RB.
1629 ///
1630 /// Default is 10 000 SURBs.
1631 #[default(10_000)]
1632 pub maximum_surb_buffer_size: usize,
1633
1634 /// If set, the Session recipient (Exit) will notify the Session initiator (Entry) about
1635 /// its SURB balance for the Session using keep-alive packets periodically.
1636 ///
1637 /// Keep in mind that each notification also costs 1 SURB, so the notification period should
1638 /// not be too frequent.
1639 ///
1640 /// Default is None (no notification sent to the client), minimum is 1 second.
1641 #[default(None)]
1642 pub surb_balance_notify_period: Option<Duration>,
1643
1644 /// If set, the Session initiator (Entry) will notify the Session recipient (Exit) about
1645 /// the local SURB balancer target using keep-alive packets from the SURB balancer.
1646 ///
1647 /// This is useful when the client plans to change the SURB balancer target dynamically.
1648 ///
1649 /// Default is true.
1650 #[default(true)]
1651 pub surb_target_notify: bool,
1652
1653 /// Maximum number of concurrent sessions allowed.
1654 ///
1655 /// Default is 10_000.
1656 #[default(10_000)]
1657 pub maximum_sessions: usize,
1658
1659 /// How many packets can be buffered if the [`HoprSession`] input socket is not fast enough.
1660 ///
1661 /// Controls the capacity of the internal `crossfire` channel used for each session slot.
1662 ///
1663 /// Default is 10 000.
1664 #[default(10000)]
1665 pub session_forward_capacity: usize,
1666
1667 /// Configuration of the PIX protocol for the Exit nodes.
1668 pub pix_config: IncomingSessionPixConfig,
1669
1670 /// Maximum number of SSA commitments this node, acting as an Entry, will accept in a single
1671 /// [`SsaServerCommitmentMessage`].
1672 ///
1673 /// This is the Entry's protection against a misbehaving Exit, not a preference: every accepted
1674 /// entry costs a full `new_ssa_commitment`, thousands of outbound `SsaCommit` packets and its own
1675 /// on-chain deposit, so without a cap one inbound packet amplifies into minutes of CPU, a large
1676 /// packet burst, and as many simultaneous deposits as the wire format admits. An over-cap request
1677 /// is rejected in full, before any commitment is generated or any `ReadyToDeposit` is emitted, and
1678 /// the Exit is told with a `SessionError` — see `refuse_ssa_request`.
1679 ///
1680 /// It must be at least the `ssas_per_request` of every Exit this node connects to — see
1681 /// [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request) for why a mismatch
1682 /// loses every Session.
1683 ///
1684 /// Clamped to `1..=`[`MAX_SSA_BATCH_SIZE`] in [`SessionManager::new`].
1685 ///
1686 /// Defaults to [`DEFAULT_MAX_SSAS_PER_SSA_REQUEST`] (2).
1687 #[default(DEFAULT_MAX_SSAS_PER_SSA_REQUEST)]
1688 pub max_ssas_per_ssa_request: usize,
1689
1690 /// How long the Exit waits for the session server to say whether a Session is admitted.
1691 ///
1692 /// The wait sits inside the peer's own initiation timeout, so it has to be comfortably shorter
1693 /// than [`initiation_timeout_base`](Self::initiation_timeout_base): a server slower than the
1694 /// peer is patient refuses every Session it is asked about, and does so after the peer has
1695 /// already given up. [`SessionManager::new`] rejects a configuration where it is not.
1696 ///
1697 /// Expiring refuses the Session — the alternative, admitting on the node's own terms, would let
1698 /// an overloaded or wedged server silently disable every rule the operator wrote.
1699 ///
1700 /// Default is 2 seconds.
1701 #[default(Duration::from_secs(2))]
1702 pub session_admission_timeout: Duration,
1703}
1704
1705// Type-erased sink used by the `SessionManager` to notify about newly incoming sessions.
1706// The errors produced by the underlying sink are remapped into `SessionManagerError`.
1707type BoxSink<T> = Pin<Box<dyn Sink<T, Error = SessionManagerError> + Send>>;
1708
1709type SessionNotifiers = (
1710 Arc<hopr_utils::runtime::prelude::Mutex<BoxSink<IncomingSession>>>,
1711 crossfire::MTx<crossfire::mpsc::Array<(SessionId, ClosureReason)>>,
1712);
1713
1714// Sink for processing Start protocol messages.
1715// Must be within Arc to be shared across SessionManager clones.
1716// The inner OnceLock is set once in `start()` and read in `dispatch_message`.
1717type StartProtocolMsgSink = Arc<OnceLock<crossfire::MTx<crossfire::mpsc::Array<(HoprPseudonym, HoprStartProtocol)>>>>;
1718
1719/// PIX protocol toolbox to enable [`SessionManager`] to use PIX protocol.
1720#[derive(Clone)]
1721pub struct PixToolbox {
1722 share_generator: Arc<SsaShareGenerator<HoprPixSpec>>,
1723 share_processor: Arc<SsaReconstructor<HoprPixSpec>>,
1724 pix_events: crossfire::MTx<crossfire::mpsc::Array<HoprSessionOutPixEvent>>,
1725}
1726
1727impl PixToolbox {
1728 pub fn new(
1729 share_generator: Arc<SsaShareGenerator<HoprPixSpec>>,
1730 share_processor: Arc<SsaReconstructor<HoprPixSpec>>,
1731 ) -> (Self, impl futures::Stream<Item = HoprSessionOutPixEvent>) {
1732 let (pix_events, pix_events_rx) = crossfire::mpsc::bounded_blocking_async::<HoprSessionOutPixEvent>(1024);
1733 (
1734 Self {
1735 share_generator,
1736 share_processor,
1737 pix_events,
1738 },
1739 pix_events_rx.into_stream(),
1740 )
1741 }
1742}
1743
1744/// Manages lifecycles of Sessions.
1745///
1746/// Once the manager is [started](SessionManager::start), the [`SessionManager::dispatch_message`]
1747/// should be called for each [`ApplicationData`] received by the node.
1748/// This way, the `SessionManager` takes care of proper Start sub-protocol message processing
1749/// and correct dispatch of Session-related packets to individual existing Sessions.
1750///
1751/// Secondly, the manager can initiate new outgoing sessions via [`SessionManager::new_session`],
1752/// probe sessions using [`SessionManager::ping_session`]
1753/// and list them via [`SessionManager::active_sessions`].
1754///
1755/// Since the `SessionManager` operates over the HOPR protocol,
1756/// the message transport `S` is required.
1757/// Such transport must also be `Clone`, since it will be cloned into all the created [`HoprSession`] objects.
1758///
1759/// ## SURB balancing
1760///
1761/// The manager also can take care of automatic [SURB balancing](SurbBalancerConfig) per Session.
1762///
1763/// With each packet sent from the session initiator over to the receiving party, zero to 2 SURBs might be delivered.
1764/// When the receiving party wants to send reply packets back, it must consume 1 SURB per packet. This
1765/// means that if the difference between the SURBs delivered and SURBs consumed is negative, the receiving party
1766/// might soon run out of SURBs. If SURBs run out, the reply packets will be dropped, causing likely quality of
1767/// service degradation.
1768///
1769/// In an attempt to counter this effect, there are two co-existing automated modes of SURB balancing:
1770/// *local SURB balancing* and *remote SURB balancing*.
1771///
1772/// ### Local SURB balancing
1773///
1774/// Local SURB balancing is performed on the sessions that were initiated by another party (and are
1775/// therefore incoming to us).
1776/// The local SURB balancing mechanism continuously evaluates the rate of SURB consumption and retrieval,
1777/// and if SURBs are running out, the packet egress shaping takes effect. This by itself does not
1778/// avoid the depletion of SURBs but slows it down in the hope that the initiating party can deliver
1779/// more SURBs over time. This might happen either organically by sending effective payloads that
1780/// allow non-zero number of SURBs in the packet, or non-organically by delivering KeepAlive messages
1781/// via *remote SURB balancing*. Both are governed by the initiator's estimate of our buffer, so
1782/// signalling SURB distress is what tells it to keep the organic ones coming.
1783///
1784/// The egress shaping is done automatically, unless the Session initiator sets the [`Capability::NoRateControl`]
1785/// flag during Session initiation.
1786///
1787/// ### Remote SURB balancing
1788///
1789/// Remote SURB balancing is performed by the Session initiator. The SURB balancer estimates the number of SURBs
1790/// delivered to the other party, and also the number of SURBs consumed by seeing the amount of traffic received
1791/// in replies.
1792/// When enabled, a desired target level of SURBs at the Session counterparty is set. According to measured
1793/// inflow and outflow of SURBs to/from the counterparty, the production of non-organic SURBs is started
1794/// via keep-alive messages (sent to counterparty) and is controlled to maintain that target level.
1795///
1796/// In other words, the Session initiator tries to compensate for the usage of SURBs by the counterparty by
1797/// sending new ones via the keep-alive messages.
1798///
1799/// The same estimate also governs *organic* production: once the counterparty is estimated to be at
1800/// its target, outgoing Session data packets carry no SURBs at all, and they return to one per packet
1801/// as soon as the estimate falls back below target. Without that, a balancer could correctly silence
1802/// its keep-alives while organic SURBs kept pouring into a full buffer — where each one evicts an
1803/// older SURB and destroys the PIX share it was carrying. A `SurbDistress` or `OutOfSurbs` signal
1804/// from the counterparty overrides the estimate and restores production immediately, which is what
1805/// makes it safe to act on an estimate that forward-path loss can inflate.
1806///
1807/// This mechanism is configurable via the `surb_management` field in [`SessionClientConfig`].
1808///
1809/// ### Possible scenarios
1810///
1811/// There are 4 different scenarios of local vs. remote SURB balancing configuration, but
1812/// an equilibrium (= matching the SURB production and consumption) is most likely to be reached
1813/// only when both are configured (the ideal case below):
1814///
1815/// #### 1. Ideal local and remote SURB balancing
1816///
1817/// 1. The Session recipient (Exit) set the `initial_return_session_egress_rate`, `max_surb_buffer_duration` and
1818/// `maximum_surb_buffer_size` values in the [`SessionManagerConfig`].
1819/// 2. The Session initiator (Entry) sets the [`target_surb_buffer_size`](SurbBalancerConfig) which matches the
1820/// [`maximum_surb_buffer_size`](SessionManagerConfig) of the counterparty.
1821/// 3. The Session initiator (Entry) does *NOT* set the [`Capability::NoRateControl`] capability flag when opening
1822/// Session.
1823/// 4. The Session initiator (Entry) sets [`max_surbs_per_sec`](SurbBalancerConfig) slightly higher than the
1824/// `maximum_surb_buffer_size / max_surb_buffer_duration` value configured at the counterparty.
1825///
1826/// In this situation, the maximum Session egress from Exit to the Entry is given by the
1827/// `maximum_surb_buffer_size / max_surb_buffer_duration` ratio. If there is enough bandwidth,
1828/// the (remote) SURB balancer sending SURBs to the Exit will stabilize roughly at this rate of SURBs/sec,
1829/// and the whole system will be in equilibrium during the Session's lifetime (under ideal network conditions).
1830///
1831/// #### 2. Remote SURB balancing only
1832///
1833/// 1. The Session initiator (Entry) *DOES* set the [`Capability::NoRateControl`] capability flag when opening Session.
1834/// 2. The Session initiator (Entry) sets `max_surbs_per_sec` and `target_surb_buffer_size` values in
1835/// [`SurbBalancerConfig`]
1836///
1837/// In this one-sided situation, the Entry node floods the Exit node with SURBs,
1838/// only based on its estimated consumption of SURBs at the Exit. The Exit's egress is not
1839/// rate-limited at all. If the Exit runs out of SURBs at any point in time, it will simply drop egress packets.
1840///
1841/// This configuration could potentially only lead to an equilibrium
1842/// when the `SurbBalancer` at the Entry can react fast enough to Exit's demand.
1843///
1844/// #### 3. Local SURB balancing only
1845///
1846/// 1. The Session recipient (Exit) set the `initial_return_session_egress_rate`, `max_surb_buffer_duration` and
1847/// `maximum_surb_buffer_size` values in the [`SessionManagerConfig`].
1848/// 2. The Session initiator (Entry) does *NOT* set the [`Capability::NoRateControl`] capability flag when opening
1849/// Session.
1850/// 3. The Session initiator (Entry) does *NOT* set the [`SurbBalancerConfig`] at all when opening Session.
1851///
1852/// In this one-sided situation, the Entry node does not provide any additional SURBs at all (except the
1853/// ones that are naturally carried by the egress packets which have space to hold SURBs). It relies
1854/// only on the Session egress limiting of the Exit node.
1855/// The Exit will limit the egress roughly to the rate of natural SURB occurrence in the ingress.
1856///
1857/// This configuration could potentially only lead to an equilibrium when uploading non-full packets
1858/// (ones that can carry at least a single SURB), and the Exit's egress is limiting itself to such a rate.
1859/// If Exit's egress reaches low values due to SURB scarcity, the upper layer protocols over Session might break.
1860///
1861/// #### 4. No SURB balancing on each side
1862///
1863/// 1. The Session initiator (Entry) *DOES* set the [`Capability::NoRateControl`] capability flag when opening Session.
1864/// 2. The Session initiator (Entry) does *NOT* set the [`SurbBalancerConfig`] at all when opening Session.
1865///
1866/// In this situation, no additional SURBs are being produced by the Entry and no Session egress rate-limiting
1867/// takes place at the Exit.
1868///
1869/// This configuration can only lead to an equilibrium when Entry sends non-full packets (ones that carry
1870/// at least a single SURB) and the Exit is consuming the SURBs (Session egress) at a slower or equal rate.
1871/// Such configuration is very fragile, as any disturbances in the SURB flow might lead to a packet drop
1872/// at the Exit's egress.
1873///
1874/// ### SURB decay
1875///
1876/// In a hypothetical scenario of a non-zero packet loss, the Session initiator (Entry) might send a
1877/// certain number of SURBs to the Session recipient (Exit), but only a portion of it is actually delivered.
1878/// The Entry has no way of knowing that and assumes that everything has been delivered.
1879/// A similar problem happens when the Exit uses SURBs to construct return packets, but only a portion
1880/// of those packets is actually delivered to the Entry. At this point, the Entry also subtracts
1881/// fewer SURBs from its SURB estimate at the Exit.
1882///
1883/// In both situations, the Entry thinks there are more SURBs available at the Exit than there really are.
1884///
1885/// To compensate for a potential packet loss, the Entry's estimation of Exit's SURB buffer is regularly
1886/// diminished by a percentage of the `target_surb_buffer_size`, even if no incoming traffic from the
1887/// Exit is detected.
1888///
1889/// This behavior can be controlled via the `surb_decay` field of [`SurbBalancerConfig`].
1890///
1891/// ### SURB balance and target notification
1892///
1893/// The Session recipient (Exit) can notify the Session initiator (Entry) periodically about its estimated
1894/// number of SURBs for the Session. This can help the Entry to adjust its approximation of that level so
1895/// that its Local SURB balancer can better intervene.
1896/// This can be set using the `surb_balance_notify_period` field of [`SessionManagerConfig`] for the Exit.
1897///
1898/// Likewise, the Entry can inform the Exit about its desired SURB buffer target so that the Exit
1899/// can better accommodate its Remote SURB balancing.
1900/// This can be set using the `surb_target_notify` field of the [`SessionManagerConfig`] of each new Session.
1901///
1902/// Both mechanisms leverage the Keep Alive message to report the respective values.
1903///
1904/// ## PIX (Protocol for Incentivization of eXits) Protocol Flow
1905///
1906/// When a Session is opened with the [`Capability::UsePIX`] flag, the following protocol
1907/// runs between the Entry (initiator) and Exit (recipient) to provide on-chain payment
1908/// guarantees for the data relayed through the Session.
1909///
1910/// ### 1. PIX Parameter Negotiation (Session Initiation)
1911///
1912/// During [`SessionManager::new_session`], the Entry encodes its PIX SSA (Session Stealth
1913/// Address) parameters — a [`PixParams`] quadruple of `polys_per_ssa`, `shares_per_poly`,
1914/// `surplus_shares` and the curve `suite` — into the upper 32 bits of the 64-bit options word
1915/// carried in the most significant 64 bits of the `StartSession.additional_data` field, via
1916/// [`PixParams::into_additional_data`]. The first two describe how many polynomials and shares each
1917/// SSA will use; the third is how many extra shares per polynomial the Entry emits to absorb losses. Those three define
1918/// the data quota per SSA, which is `polys × (threshold + surplus) × SESSION_MTU` — the surplus is priced in rather
1919/// than free, since a cycle emits it whether or not any share is lost (see `pix_params_to_quota`).
1920///
1921/// The fourth is not a dimension and does not enter the quota: it names the elliptic curve the
1922/// Entry's build instantiates PIX over, which fixes the width of every curve-sized field later in
1923/// the handshake. It is fixed at build time on both sides and is therefore not negotiated — the Exit
1924/// refuses anything but its own, below.
1925///
1926/// What is announced is built from the installed [`SsaShareGenerator`]'s
1927/// [`SsaGeneratorConfig`](hopr_protocol_pix::SsaGeneratorConfig), never from the caller: the
1928/// generator is what produces the shares that go on the wire, so advertising anything else would
1929/// let the Session proceed while emitting shares the Exit cannot reconstruct. The dimensions are
1930/// therefore not configurable per Session — [`Capability::UsePIX`] is the whole per-Session switch,
1931/// and a node with no PIX toolbox installed refuses the Session. That check runs before the
1932/// initiation challenge slot is reserved, so a node that cannot serve PIX at all cannot have its
1933/// challenge slots exhausted by repeated requests for it.
1934///
1935/// The caller never needs to know the resulting quota in advance: it is computed node-side and
1936/// handed back per SSA in [`AgreedSsaQuota::quota_per_ssa`], which is what sizes the deposit.
1937///
1938/// On the Exit side, `check_pix_params` validates these parameters against:
1939/// - The protocol ranges, which [`PixParams::try_from_additional_data`] enforces as it unpacks.
1940/// - The configured [`IncomingSessionPixConfig::quota_range`] (by default ≈227 MiB–908 MiB). Dynamic admission checks
1941/// the smallest `batch_size × quota_per_ssa` up to the configured maximum; fixed admission checks the per-SSA quota
1942/// itself and retains the configured exact batch.
1943/// - Optionally, [`IncomingSessionPixConfig::enforce_pix`] rejects Sessions that do not offer PIX.
1944/// - The Exit only checks the *product* `polys × (threshold + surplus)`, not the individual values, so the Entry can
1945/// split it to suit its computing power. The computation is easily parallelizable in the number of polynomials, but
1946/// not in threshold. The surplus is inside that product, so redundancy is bought rather than taken: a cycle emits
1947/// `threshold + surplus` shares per polynomial come what may, and the deposit covers all of them.
1948///
1949/// If parameters are rejected, a [`StartErrorReason::UnacceptablePixParams`] error is returned.
1950///
1951/// ### 2. Exit SSA Request (`SsaRequest` → Entry)
1952///
1953/// Once the PIX parameters are accepted, the Exit spawns a `SessionPixSupervisor`, whose opening
1954/// `RequestSsa` action has `send_ssa_request` create a new SSA commitment via the server-side
1955/// [`SsaReconstructor`]. This produces an *Exit commitment* (a group element) that is sent back to
1956/// the Entry as a [`SsaServerCommitmentMessage`].
1957///
1958/// One action, and therefore one message, carries the batch selected during admission: no more than
1959/// [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request) SSAs at
1960/// contiguous indices, sharing the single `params` field, since every SSA in a Session uses the same
1961/// dimensions. The Entry caps what it will accept at
1962/// [`SessionManagerConfig::max_ssas_per_ssa_request`], and rejects an over-cap request in full while
1963/// replying with an `UnacceptablePixParams` [`StartErrorType`], so the Exit does not have to infer the
1964/// refusal from a deadline. The default is a batch of one, which is byte-for-byte the unbatched
1965/// exchange.
1966///
1967/// From here the supervisor owns the cycle's deadlines: `max_ssa_delivery_time` for the Entry's
1968/// commitment, then `max_deposit_wait` for the funds, then the recovery deadlines. Missing any of
1969/// them closes the Session with `ClosureReason::PixFailure`. The first two are multiplied by the batch
1970/// size, because a batch asks the Entry for that many commitment sets and that many deposits before
1971/// any of them can fairly be called late.
1972///
1973/// ### 3. Entry SSA Commitment (`SsaCommit` → Exit)
1974///
1975/// Upon receiving the [`SsaServerCommitmentMessage`], the Entry's `handle_ssa_request`
1976/// generates a *client commitment* using the shared [`SsaShareGenerator`] (which is also
1977/// used by the packet pipeline to embed PIX shares into return-path SURBs). The client
1978/// commitment is combined with the Exit commitment to derive the on-chain deposit address
1979/// via [`HoprPixSpec::group_to_deposit_address`].
1980///
1981/// The Entry then sends one or more [`SsaClientCommitmentMessage`]s back to the Exit and
1982/// emits a [`HoprSessionOutPixEvent::ReadyToDeposit`] to the upper layer, signaling that
1983/// funds can be deposited at the computed address.
1984///
1985/// "One or more" is hundreds at the deployed dimensions — one commitment per polynomial, chunked to
1986/// packet size — and the Exit can use *none* of it until every message has landed, since the SSA
1987/// commitment is the sum of them all. A single lost packet therefore used to cost the whole Session
1988/// on `max_ssa_delivery_time`, typically after the Entry had already been told to fund the address.
1989///
1990/// So a partially delivered commitment is repaired rather than waited out. Each arriving message
1991/// reports `SessionPixEvent::CommitmentProgress`, which arms (and re-arms) the supervisor's
1992/// [`commitment_recommit_interval`](crate::SupervisorConfig::commitment_recommit_interval); if
1993/// delivery then goes quiet with the set incomplete, `RequestCommitmentRetransmission` has
1994/// `send_commitment_retransmission_request` ask the Entry — via an `SsaRequest` whose
1995/// [`missing`](hopr_protocol_start::SsaServerCommitmentMessage::missing) scope names the polynomial
1996/// runs the reconstructor never received — to re-send exactly those. The Entry answers in
1997/// `handle_ssa_recommit` from the bytes it sent the first time, advancing nothing and announcing no
1998/// second deposit. A cycle for which *nothing* arrived never asks: it has no scope to name, and the
1999/// delivery deadline remains what ends it.
2000///
2001/// ### 4. Deposit Awaiting (Exit Side)
2002///
2003/// The Exit receives the client commitment messages in `handle_ssa_commit`, inserts the
2004/// coefficient commitments into the [`SsaReconstructor`], and extracts the deposit address.
2005/// It emits [`HoprSessionOutPixEvent::DepositNeeded`] to the upper layer with the
2006/// [`AgreedSsaQuota`] and a channel to confirm the deposit.
2007///
2008/// A verifiable commitment is what starts the deposit clock, so the supervisor is told
2009/// (`CommitmentVerified`) before anything can be reported against it. A `PixDepositObserver` task
2010/// then forwards every confirmation arriving on that channel as `DepositConfirmed`: top-ups
2011/// accumulate, and the supervisor is what decides when enough has landed. The observer carries no
2012/// timeout of its own — `max_deposit_wait` is the deadline, and a second authority racing the
2013/// first is exactly what the supervisor exists to prevent. If the upper layer drops the sender
2014/// without ever confirming, the observer reports that (`DepositObserverClosed`) rather than
2015/// letting the deadline run out on a deposit that is never coming.
2016///
2017/// Once the deposit suffices, the SSA moves to *recovering*. Its recovery deadlines start when it
2018/// reaches the front, and its deposit releases the egress gate only while it is there. When a paid
2019/// front leaves—whether recovered or retired after a tolerated failure—an unfunded successor
2020/// restores its own bounded predeposit allowance; an already funded successor stays open but starts
2021/// a fresh served-without-progress ceiling.
2022///
2023/// ### 5. SSA Collection, Recovery and Pipelining
2024///
2025/// As the Entry sends return-path SURBs during the Session, each SURB can carry a PIX
2026/// share generated from the client's polynomial set. The Exit's [`SsaReconstructor`]
2027/// collects these shares and reports how far the cycle has got via
2028/// [`HoprSessionInPixEvent::RecoveryProgress`]; `dispatch_pix_event` forwards those snapshots to
2029/// the supervisor, which uses them both to reset `max_recovery_idle` and to keep the gate serving.
2030///
2031/// When the last cycle of a batch reaches the *early recovery threshold* (≈85%), an
2032/// [`HoprSessionInPixEvent::SsaAlmostRecovered`] event fires and the supervisor answers with a
2033/// `RequestSsa` action for the next batch — pipelining the costly commitment exchange with the tail
2034/// of share collection. If that cycle is still awaiting its own commitment or deposit, the request
2035/// waits for funding; if two generations still own cycle state, it waits for one to be fully
2036/// released. Those checks are independent of the configurable share-order policy.
2037///
2038/// Once fully recovered, [`HoprSessionInPixEvent::SsaRecovered`] fires, allowing the Exit to
2039/// unlock and redeem the deposited funds. The reconstructor releases the live cycle immediately;
2040/// the supervisor retains a short tombstone, whose expiry emits `RetireSsa` to drop that cycle's
2041/// [`SsaCommitmentGuard`] and abort its deposit observer. The next batch is requested here if
2042/// `SsaAlmostRecovered` has not already done so. Observers are keyed by SSA index, so retiring one
2043/// cycle never cancels a pipelined successor's.
2044///
2045/// ### 6. Unverifiable Shares
2046///
2047/// Shares are not checked individually. Once a polynomial has collected `threshold` of them,
2048/// the reconstructor interpolates its constant term and compares it against the commitment; if
2049/// they disagree, at least one of those shares did not come from the committed polynomial and an
2050/// [`HoprSessionInPixEvent::UnverifiableShares`] event fires, carrying the reconstructor's running
2051/// total for the SSA rather than a delta.
2052///
2053/// The first such report closes the Session, unconditionally and with nothing to configure. The
2054/// failed polynomial is never retried, and the SSA is the sum of every polynomial's constant term,
2055/// so the cycle is already unrecoverable and can never pay; closing immediately caps what a
2056/// malicious Entry is served at `threshold` packets. The running total is logged rather than
2057/// accumulated. See `SessionPixSupervisor::on_unverifiable_shares` for why no tolerance is offered.
2058///
2059/// ### Configuring PIX at the Exit
2060///
2061/// The Exit configures PIX via [`IncomingSessionPixConfig`] within [`SessionManagerConfig`].
2062///
2063/// The [`PixToolbox`] (holding the [`SsaShareGenerator`] and [`SsaReconstructor`]) must
2064/// be provided via [`SessionManager::start`] for PIX to function.
2065pub struct SessionManager<S> {
2066 // Keeps track of Session initiations requests on the Client side.
2067 session_initiations: SessionInitiationCache,
2068 session_notifiers: Arc<OnceLock<SessionNotifiers>>,
2069 start_protocol_tx: StartProtocolMsgSink,
2070 /// Authoritative session count for admission control.
2071 /// Incremented atomically inside `allocate_session_slot` before the cache insertion,
2072 /// and decremented at every removal path (explicit close, eviction, guard rollback).
2073 active_sessions: Arc<std::sync::atomic::AtomicUsize>,
2074 sessions: moka::sync::Cache<SessionId, SessionSlot>,
2075 msg_sender: Arc<OnceLock<S>>,
2076 pix_toolbox: Arc<OnceLock<PixToolbox>>,
2077 cfg: SessionManagerConfig,
2078 /// Per-SessionId waiters notified when a new session slot is allocated. Lets message
2079 /// handlers that arrive before the slot insertion completes (e.g. SsaRequest vs
2080 /// SessionEstablished) await the slot once instead of busy-looping with sleeps.
2081 /// Keyed by SessionId so that only waiters for the relevant session are woken.
2082 slot_allocated: Arc<Mutex<HashMap<SessionId, Vec<oneshot::Sender<()>>>>>,
2083 /// Serialises `handle_ssa_request` per pseudonym.
2084 ///
2085 /// Start messages are processed under `for_each_concurrent`, so without this several
2086 /// `SsaRequest`s for one pseudonym run at once — and each reads the successor gate's admission
2087 /// state before any of them has advanced it. Every racer passes, and the Entry commits to (and
2088 /// funds) as many batches as the Exit cared to send, which is exactly what the gate exists to
2089 /// stop. The generator's own monotonic index is the backstop that keeps this from being a
2090 /// correctness hole, but it only rejects *equal or lower* indices: an Exit numbering its batches
2091 /// upwards races past it.
2092 ///
2093 /// An async lock rather than a blocking one because the guarded region awaits — commitment
2094 /// generation goes to a blocking pool, and publication awaits the transport.
2095 ///
2096 /// A `moka` cache rather than a map, for its idle eviction: a `HashMap` keyed by pseudonym would
2097 /// otherwise retain one entry per Session for the life of the process.
2098 ssa_request_locks: moka::future::Cache<HoprPseudonym, Arc<futures::lock::Mutex<()>>>,
2099 /// Live reconstructor-cycle bytes this node has committed to, summed over every PIX Session.
2100 ///
2101 /// The counterpart of `active_sessions`, and the reason that one is not sufficient: a Session is
2102 /// admitted on a slot count, but what it costs the reconstructor is set by the dimensions its
2103 /// peer offered. Charged in `handle_incoming_session_initiation` and returned by
2104 /// [`CycleBudgetReservation`]'s `Drop`, so no removal path has to remember to decrement it.
2105 live_cycle_bytes: Arc<std::sync::atomic::AtomicU64>,
2106 /// Where incoming Sessions are put to the session server for an admission decision.
2107 ///
2108 /// Unset on a node that runs no session server — an Entry, or a relay — where there is nobody to
2109 /// ask and every Session is admitted on this node's own terms.
2110 /// Behind a `Mutex` so every request goes through *one* sender.
2111 ///
2112 /// `futures::channel::mpsc` gives each cloned `Sender` its own guaranteed slot on top of the
2113 /// buffer, so sending through a fresh clone each time would let the queue grow with the number
2114 /// of concurrent initiations — the bound would not bound. `try_send` never blocks, so the lock
2115 /// is held only for the enqueue itself.
2116 admission_tx: Arc<OnceLock<parking_lot::Mutex<SessionAdmissionSink>>>,
2117}
2118
2119impl<S> Clone for SessionManager<S> {
2120 fn clone(&self) -> Self {
2121 Self {
2122 session_initiations: self.session_initiations.clone(),
2123 session_notifiers: self.session_notifiers.clone(),
2124 start_protocol_tx: self.start_protocol_tx.clone(),
2125 active_sessions: self.active_sessions.clone(),
2126 live_cycle_bytes: self.live_cycle_bytes.clone(),
2127 sessions: self.sessions.clone(),
2128 cfg: self.cfg.clone(),
2129 msg_sender: self.msg_sender.clone(),
2130 pix_toolbox: self.pix_toolbox.clone(),
2131 slot_allocated: Arc::clone(&self.slot_allocated),
2132 ssa_request_locks: self.ssa_request_locks.clone(),
2133 admission_tx: self.admission_tx.clone(),
2134 }
2135 }
2136}
2137
2138fn session_config(cfg: &SessionManagerConfig, capabilities: Capabilities) -> HoprSessionConfig {
2139 session_config_with(cfg, capabilities, None)
2140}
2141
2142/// As [`session_config`], with the initiating session's own head-of-line bound.
2143///
2144/// `None` inherits the node default; `Some(0)` disables the bound for this session; `Some(n)` sets
2145/// it. The zero-disables convention matches the node setting, so the per-session
2146/// and node-wide knobs cannot mean opposite things by the same value.
2147///
2148/// Sessions accepted from a peer pass `None`: the bound governs how *we* reassemble what arrives,
2149/// so it is ours to choose, not the initiator's to impose on us.
2150fn session_config_with(
2151 cfg: &SessionManagerConfig,
2152 capabilities: Capabilities,
2153 max_frames_behind_gap: Option<usize>,
2154) -> HoprSessionConfig {
2155 // Only a session that cannot recover the missing frame should abandon it early. With
2156 // retransmission the gap is a request away, so waiting is productive and cutting it short
2157 // would discard data that was on its way back; without it the wait is for something that is
2158 // never coming, and every frame behind the gap is held for nothing.
2159 let can_retransmit =
2160 capabilities.contains(Capability::RetransmissionAck) || capabilities.contains(Capability::RetransmissionNack);
2161
2162 // The session's own value when it stated one, the node's otherwise. `Some(0)` disables the
2163 // bound at either level -- a threshold of zero would abandon every gap before a single frame
2164 // arrived behind it, which nobody wants and which the sequencer would silently clamp to one.
2165 let bound = match max_frames_behind_gap.or(cfg.max_frames_behind_gap) {
2166 Some(0) | None => None,
2167 Some(n) => Some(n),
2168 };
2169
2170 HoprSessionConfig {
2171 capabilities,
2172 frame_mtu: cfg.frame_mtu,
2173 frame_timeout: cfg.max_frame_timeout,
2174 max_buffered_segments: cfg.max_buffered_segments,
2175 max_frames_behind_gap: (!can_retransmit).then_some(bound).flatten(),
2176 }
2177}
2178
2179#[cfg(feature = "telemetry")]
2180fn initialize_session_telemetry(
2181 session_id: SessionId,
2182 cfg: &SessionManagerConfig,
2183 capabilities: Capabilities,
2184 surb_estimator: Option<&AtomicSurbFlowEstimator>,
2185 surb_mgmt: Option<&Arc<BalancerStateValues>>,
2186) {
2187 initialize_session_metrics(session_id, session_config(cfg, capabilities));
2188 set_session_state(&session_id, SessionLifecycleState::Active);
2189 if let (Some(estimator), Some(mgmt)) = (surb_estimator, surb_mgmt) {
2190 set_session_balancer_data(&session_id, estimator.clone(), mgmt.clone());
2191 }
2192}
2193
2194/// Caps how many SURBs an outgoing Session data packet may carry, per the SURB balancer.
2195///
2196/// The default cap of `1` follows the balancer's target. Explicit limits of `0` or more than `1`
2197/// take precedence; `usize::MAX` leaves the packet's existing limit untouched.
2198///
2199/// Otherwise the cap comes from [`BalancerStateValues::organic_surbs_per_packet`], which yields `0`
2200/// once the counterparty is estimated to be at its target. That zero is the whole point of the
2201/// function: without it, the balancer can silence its keep-alives and still have organic SURBs
2202/// pouring into a full buffer, where each one evicts an older SURB and destroys the PIX share it
2203/// carried.
2204///
2205/// Nothing else has to change to keep the SURB accounting straight. This runs in front of the
2206/// counting sink, so `estimate_surbs_with_msg` reports the capped figure; and the PIX share for a
2207/// SURB is drawn per SURB while it is built, so a cap of `0` draws no share at all and leaves it
2208/// queued for a packet that can actually deliver it.
2209fn cap_organic_surbs(data: &mut ApplicationDataOut, max_surbs: usize, surb_mgmt: &BalancerStateValues) {
2210 if max_surbs != usize::MAX {
2211 data.packet_info.get_or_insert_default().max_surbs_in_packet = if max_surbs == 1 {
2212 surb_mgmt.organic_surbs_per_packet()
2213 } else {
2214 max_surbs
2215 };
2216 }
2217}
2218
2219/// Books an incoming packet's SURBs into the Exit's flow estimator.
2220///
2221/// `saved` SURBs entered the store and `evicted` older ones were dropped to make room for them. Both
2222/// are recorded so that `produced - consumed - evicted` tracks what the store actually holds: without
2223/// the eviction term the estimate stays inflated by every SURB the full store ever discarded, long
2224/// after they are gone -- which leaves the keep-alive level report and the PIX fill reserve both
2225/// reading a level that never falls. Split out from the receive closure so the accounting can be
2226/// unit-tested on its own.
2227fn account_incoming_surbs(estimator: &AtomicSurbFlowEstimator, saved: u64, evicted: u64) {
2228 estimator
2229 .produced
2230 .fetch_add(saved, std::sync::atomic::Ordering::Relaxed);
2231 estimator
2232 .evicted
2233 .fetch_add(evicted, std::sync::atomic::Ordering::Relaxed);
2234}
2235
2236async fn send_via_msg_sender<S, D>(
2237 msg_sender: &mut S,
2238 routing: DestinationRouting,
2239 data: D,
2240 error_context: &'static str,
2241) -> errors::Result<()>
2242where
2243 S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Unpin,
2244 S::Error: std::error::Error + Send + Sync + Clone + 'static,
2245 D: TryInto<ApplicationData>,
2246 D::Error: std::error::Error + Send + Sync + 'static,
2247{
2248 let app_data: ApplicationData = data.try_into().map_err(SessionManagerError::other)?;
2249 msg_sender
2250 .send((routing, ApplicationDataOut::with_no_packet_info(app_data)))
2251 .timeout(futures_time::time::Duration::from(EXTERNAL_SEND_TIMEOUT))
2252 .await
2253 .map_err(|_| {
2254 error!("timeout sending {error_context}");
2255 TransportSessionError::Timeout
2256 })?
2257 .map_err(|error| {
2258 error!(%error, "failed to send {error_context}");
2259 SessionManagerError::other(error)
2260 })?;
2261 Ok(())
2262}
2263
2264impl<S> SessionManager<S>
2265where
2266 S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Clone + Send + Sync + Unpin + 'static,
2267 S::Error: std::error::Error + Send + Sync + Clone + 'static,
2268{
2269 /// Creates a new instance given the [`config`](SessionManagerConfig).
2270 pub fn new(mut cfg: SessionManagerConfig) -> Self {
2271 let maximum_sessions = cfg.maximum_sessions;
2272 cfg.surb_balance_notify_period = cfg
2273 .surb_balance_notify_period
2274 .map(|p| p.max(MIN_SURB_BUFFER_NOTIFICATION_PERIOD));
2275 cfg.minimum_surb_buffer_duration = cfg.minimum_surb_buffer_duration.max(MIN_SURB_BUFFER_DURATION);
2276
2277 // Ensure the Frame MTU is at least the size of the Session segment MTU payload
2278 cfg.frame_mtu = cfg.frame_mtu.max(SESSION_MTU);
2279 cfg.max_frame_timeout = cfg.max_frame_timeout.max(MIN_FRAME_TIMEOUT);
2280
2281 // Both SSA batch knobs are range-validated in `HoprProtocolConfig`, but nothing in this crate
2282 // calls `validate()` — clamp here so a programmatically built config cannot ask for a batch of
2283 // zero (no request would ever be sent) or one large enough to blow past what
2284 // `MAX_SSA_BATCH_SIZE` exists to bound.
2285 cfg.max_ssas_per_ssa_request = cfg.max_ssas_per_ssa_request.clamp(1, MAX_SSA_BATCH_SIZE);
2286 cfg.pix_config.supervision.ssas_per_request =
2287 cfg.pix_config.supervision.ssas_per_request.clamp(1, MAX_SSA_BATCH_SIZE);
2288
2289 // Every supervisor duration, for the same reason and with more riding on it. A duration the
2290 // monotonic clock cannot represent makes `Instant::checked_add` return `None`, and every phase
2291 // reads an absent deadline as *no deadline* — so an over-large value does not produce a distant
2292 // deadline, it silently disables the rule. `validate_pix_supervision` rejects such a config, but
2293 // only a node built from a config file goes through it.
2294 //
2295 // Representability is necessary and not sufficient, though: `max_recovery_idle` also has to
2296 // stay under the reconstructor's `unused_verifier_lifetime`, or the supervisor waits on a
2297 // cycle whose state was reclaimed hours earlier. Clamping only to the monotonic-clock cap left
2298 // exactly that — `Duration::MAX` became 24 h against a 30-minute default lifetime — so the
2299 // normalized config was representable and still invalid.
2300 //
2301 // The reconstructor is not installed until `start`, so the *default* configuration is what
2302 // this can normalize against. `start` re-checks against the one actually installed, which is
2303 // where a caller pairing a non-default reconstructor is caught.
2304 //
2305 // The two batch-scaled deadlines are clamped by the scaled value, since that is what is armed;
2306 // dividing by the batch size (already clamped to at least 1 above) is what keeps the product
2307 // under the cap.
2308 {
2309 use hopr_protocol_pix::SsaReconstructorConfig;
2310
2311 let sup = &mut cfg.pix_config.supervision;
2312 let cap = crate::supervision::MAX_SUPERVISOR_DURATION;
2313 let per_cycle_cap = cap / sup.ssas_per_request as u32;
2314 sup.max_ssa_delivery_time = sup.max_ssa_delivery_time.min(per_cycle_cap);
2315 sup.max_deposit_wait = sup.max_deposit_wait.min(per_cycle_cap);
2316 sup.max_recovery_time = sup.max_recovery_time.min(cap);
2317 sup.tombstone_retention_window = sup.tombstone_retention_window.min(cap);
2318
2319 // Strictly under the lifetime, which is what `validate_pix_supervision` requires; a
2320 // saturating subtraction keeps a pathologically short lifetime from wrapping. The lower
2321 // bound is not clamped: raising a too-small value would be inventing a policy rather than
2322 // bounding one, and it is what `validate_pix_supervision` reports.
2323 let idle_cap = SsaReconstructorConfig::default()
2324 .unused_verifier_lifetime
2325 .saturating_sub(Duration::from_secs(1))
2326 .min(cap);
2327 sup.max_recovery_idle = sup.max_recovery_idle.min(idle_cap);
2328
2329 // The fill heartbeat is armed as a deadline like the rest, so it takes the same cap. The
2330 // rate ceiling is clamped for a different reason: `RateController::MIN_DELAY` makes
2331 // anything above `MAX_FILL_RATE` unrepresentable, and a controller that silently ignores
2332 // the one bound on this node's self-generated egress is worse than one that is loud about
2333 // refusing it — which is what `validate_pix_supervision` does for a config that was
2334 // validated. This branch is for the programmatic ones that never were.
2335 //
2336 // The heartbeat needs the *lower* bound for the same reason the ceiling needs its upper
2337 // one, and it is the one that bites: a zero period makes `FillRate::as_packets_per_sec`
2338 // report zero, so the planner's own floor is zero, every branch of `FillPlanner::bound`
2339 // that returns the heartbeat returns a zero period, and `PixFillControl::apply` hands
2340 // `set_rate_per_unit(1, 1µs)` to the controller — which `MIN_DELAY` turns into 10 000
2341 // packets/s. The one clamp that exists to bound self-generated egress is bypassed by the
2342 // one field that was left unbounded below. The floor is the exact dual of the ceiling —
2343 // the period of `MAX_FILL_RATE` — rather than a new number, so the two cannot drift; an
2344 // inverted heartbeat/ceiling pair is already resolved ceiling-wins inside `bound`, which
2345 // is what makes any positive floor safe here.
2346 sup.fill.heartbeat = sup
2347 .fill
2348 .heartbeat
2349 .clamp(Duration::from_secs(1) / crate::supervision::MAX_FILL_RATE, cap);
2350 sup.fill.max_rate = sup.fill.max_rate.clamp(1, crate::supervision::MAX_FILL_RATE);
2351 }
2352
2353 // The admission wait is spent inside the peer's initiation timeout, so a value at or above
2354 // it buys nothing: the peer has already given up by the time the wait expires, and the
2355 // Session was refused anyway. Clamped to one leg of that budget, leaving the other for the
2356 // Start exchange the decision is part of. Clamped rather than rejected because every other
2357 // cross-field rule in this constructor normalizes, and because the ceiling is derived from a
2358 // field the caller may also have left at its default.
2359 cfg.session_admission_timeout = cfg.session_admission_timeout.min(initiation_timeout_max_one_way(
2360 cfg.initiation_timeout_base,
2361 RoutingOptions::MAX_INTERMEDIATE_HOPS,
2362 ));
2363
2364 #[cfg(all(feature = "telemetry", not(test)))]
2365 METRIC_ACTIVE_SESSIONS.set(0.0);
2366
2367 let active_sessions: Arc<std::sync::atomic::AtomicUsize> = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2368 let active_sessions_for_listener = active_sessions.clone();
2369
2370 let msg_sender = Arc::new(OnceLock::new());
2371 let initiation_timeout =
2372 2 * initiation_timeout_max_one_way(cfg.initiation_timeout_base, RoutingOptions::MAX_INTERMEDIATE_HOPS);
2373 let pix_toolbox: Arc<OnceLock<PixToolbox>> = Arc::new(OnceLock::new());
2374 Self {
2375 msg_sender: msg_sender.clone(),
2376 session_initiations: moka::sync::Cache::builder()
2377 .max_capacity(maximum_sessions as u64)
2378 .time_to_live(initiation_timeout)
2379 .build(),
2380 sessions: moka::sync::Cache::builder()
2381 .max_capacity(maximum_sessions as u64)
2382 .time_to_idle(cfg.idle_timeout)
2383 .eviction_listener(
2384 move |session_id: Arc<SessionId>, entry: SessionSlot, reason| match &reason {
2385 moka::notification::RemovalCause::Expired | moka::notification::RemovalCause::Size => {
2386 trace!(?session_id, ?reason, "session evicted from the cache");
2387 // Reconstructor state is released by `close_session` aborting the PIX
2388 // action driver: the driver owns an `SsaCommitmentGuard` per live cycle,
2389 // and dropping its future drops them. That bounds the release to the
2390 // cycles actually in flight, where enumerating every index this Session
2391 // had ever used was unbounded in its lifetime.
2392 active_sessions_for_listener.fetch_sub(1, Ordering::Relaxed);
2393 close_session(*session_id.as_ref(), entry, ClosureReason::Eviction);
2394 }
2395 _ => {}
2396 },
2397 )
2398 .build(),
2399 pix_toolbox,
2400 session_notifiers: Arc::new(OnceLock::new()),
2401 start_protocol_tx: Arc::new(OnceLock::new()),
2402 active_sessions,
2403 live_cycle_bytes: Arc::new(std::sync::atomic::AtomicU64::new(0)),
2404 cfg,
2405 slot_allocated: Arc::new(Mutex::new(HashMap::new())),
2406 // Idle rather than live TTL, and generous: the entry only has to outlive the requests that
2407 // race for it, and one evicted between two cycles is simply recreated by the next request.
2408 // Matches the generator's own per-pseudonym cache, which is what the guarded region reads.
2409 ssa_request_locks: moka::future::Cache::builder()
2410 .time_to_idle(Duration::from_secs(1800))
2411 .build(),
2412 admission_tx: Arc::new(OnceLock::new()),
2413 }
2414 }
2415
2416 /// Starts the instance with the given `msg_sender` `Sink`
2417 /// and a channel `new_session_notifier` used to notify when a new incoming session is opened to us.
2418 ///
2419 /// Optionally, the PIX processor and event sink can be provided for handling PIX protocol.
2420 /// If not specified, the `SessionManager` will not handle PIX protocol.
2421 ///
2422 /// `admission` is where incoming Sessions are put to the session server for a per-Session
2423 /// decision before they are established. Without it — an Entry, or a relay, or any caller that
2424 /// installed no session server — every Session is admitted on this node's own configured terms,
2425 /// which is the behaviour of a node that does not distinguish between targets.
2426 ///
2427 /// This method must be called prior to any calls to [`SessionManager::new_session`] or
2428 /// [`SessionManager::dispatch_message`].
2429 pub fn start<T>(
2430 &self,
2431 msg_sender: S,
2432 new_session_notifier: T,
2433 pix: Option<PixToolbox>,
2434 admission: Option<SessionAdmissionSink>,
2435 ) -> errors::Result<Vec<AbortHandle>>
2436 where
2437 T: futures::Sink<IncomingSession> + Send + 'static,
2438 T::Error: std::error::Error + Send + Sync + 'static,
2439 {
2440 // Every fallible check runs before the first `OnceLock::set` below.
2441 //
2442 // `start` publishes the manager's started state through those locks, and a `OnceLock` cannot
2443 // be un-set. Validating after `msg_sender` had been filled left a manager that reports
2444 // `is_started() == false` — no workers, no notifier — and yet refuses every retry with
2445 // `AlreadyStarted`: neither running nor recoverable, out of what is an ordinary configuration
2446 // error the caller could have corrected and retried on the same instance.
2447 if let Some(pix) = pix.as_ref() {
2448 validate_incoming_session_pix_config(&self.cfg.pix_config, MAX_ASSUMED_SESSION_PACKET_RATE)?;
2449
2450 // The authoritative cross-component check, and the first moment it can be made: the
2451 // supervisor's deadlines are meaningless without the reconstructor lifetimes they race
2452 // against, and the reconstructor arrives here rather than at construction.
2453 //
2454 // `SessionManager::new` normalizes against the *default* reconstructor config, which is
2455 // what makes the common programmatic case correct without an API break. A caller that
2456 // pairs a supervisor config with a non-default reconstructor is caught here instead — as
2457 // an error rather than a clamp, because at this point both halves were chosen
2458 // deliberately and silently overriding one of them would be the wrong answer.
2459 crate::supervision::validate_pix_supervision(
2460 &self.cfg.pix_config.supervision,
2461 pix.share_processor.config(),
2462 )?;
2463 }
2464
2465 self.msg_sender
2466 .set(msg_sender)
2467 .map_err(|_| SessionManagerError::AlreadyStarted)?;
2468
2469 if let Some(pix) = pix {
2470 self.pix_toolbox
2471 .set(pix)
2472 .map_err(|_| SessionManagerError::AlreadyStarted)?;
2473 }
2474
2475 if let Some(admission) = admission {
2476 self.admission_tx
2477 .set(parking_lot::Mutex::new(admission))
2478 .map_err(|_| SessionManagerError::AlreadyStarted)?;
2479 }
2480
2481 // Re-map the user-provided sink errors to `SessionManagerError` and erase the concrete
2482 // type so that the `SessionManager` does not need to be generic over it. This also avoids
2483 // having to spawn a separate task to forward items between channels: senders simply lock
2484 // the sink and send directly.
2485 let new_session_notifier: BoxSink<IncomingSession> =
2486 Box::pin(new_session_notifier.sink_map_err(SessionManagerError::other));
2487 let new_session_notifier = Arc::new(hopr_utils::runtime::prelude::Mutex::new(new_session_notifier));
2488
2489 let (session_close_tx, session_close_rx) =
2490 crossfire::mpsc::bounded_blocking_async(self.cfg.maximum_sessions + 10);
2491 self.session_notifiers
2492 .set((new_session_notifier, session_close_tx))
2493 .map_err(|_| SessionManagerError::AlreadyStarted)?;
2494
2495 let (start_protocol_tx, start_protocol_rx) =
2496 crossfire::mpsc::bounded_blocking_async(start_protocol_channel_capacity(&self.cfg));
2497 let _ = self.start_protocol_tx.set(start_protocol_tx);
2498
2499 let myself = self.clone();
2500 let closure_diag = hopr_utils::runtime::diagnostics::ConcurrentDiagnostics::new(
2501 "session_close_for_each_concurrent",
2502 module_path!(),
2503 file!(),
2504 line!(),
2505 );
2506 let ah_closure_notifications = hopr_utils::spawn_as_abortable_named!(
2507 "session_close_notifications",
2508 session_close_rx.into_stream().for_each_concurrent(
2509 self.cfg.maximum_sessions + 10,
2510 move |(session_id, closure_reason)| {
2511 let myself = myself.clone();
2512 let closure_diag = closure_diag.clone();
2513 closure_diag.wrap(|| {
2514 // These notifications come from the Sessions themselves once
2515 // an empty read is encountered, which means the closure was done by the
2516 // other party.
2517 //
2518 // Routed through `close_session_with_reason` rather than removing the slot
2519 // here, so that this path — the one an Exit-side session server actually
2520 // closes on — can answer with a SURB drain like every other. Identical
2521 // behaviour when it does not.
2522 if !myself.close_session_with_reason(&session_id, closure_reason) {
2523 // Do not treat this as an error
2524 debug!(
2525 ?session_id,
2526 ?closure_reason,
2527 "could not find session id to close, maybe the session is already closed"
2528 );
2529 }
2530 futures::future::ready(())
2531 })
2532 }
2533 )
2534 );
2535
2536 // This is necessary to evict expired entries from the caches if
2537 // no session-related operations happen at all.
2538 // This ensures the dangling expired sessions are properly closed
2539 // and their closure is timely notified to the other party.
2540 let myself = self.clone();
2541 let ah_session_expiration = hopr_utils::spawn_as_abortable!(async move {
2542 let jitter = hopr_api::types::crypto_random::random_float_in_range(1.0..1.5);
2543 let timeout = 2 * initiation_timeout_max_one_way(
2544 myself.cfg.initiation_timeout_base,
2545 RoutingOptions::MAX_INTERMEDIATE_HOPS,
2546 )
2547 .min(myself.cfg.idle_timeout)
2548 .mul_f64(jitter)
2549 / 2;
2550 futures_time::stream::interval(timeout.into())
2551 .for_each(|_| async {
2552 trace!("executing session cache evictions");
2553 myself.sessions.run_pending_tasks();
2554 myself.session_initiations.run_pending_tasks();
2555 })
2556 .await;
2557 });
2558
2559 // Begin processing of Start protocol messages
2560 let myself = self.clone();
2561 let ah_start_protocol = hopr_utils::spawn_as_abortable_named!(
2562 "session_start_protocol_processor",
2563 start_protocol_rx.into_stream().for_each_concurrent(
2564 Some(self.cfg.maximum_sessions + 10),
2565 move |(pseudonym, protocol_msg)| {
2566 let myself = myself.clone();
2567 async move {
2568 let result = match protocol_msg {
2569 HoprStartProtocol::StartSession(session_req) => {
2570 myself.handle_incoming_session_initiation(pseudonym, session_req).await
2571 }
2572 HoprStartProtocol::SessionEstablished(est) => myself.handle_session_established(est).await,
2573 HoprStartProtocol::SessionError(error_type) => {
2574 myself.handle_session_error(error_type).await
2575 }
2576 HoprStartProtocol::KeepAlive(msg) => myself.handle_keep_alive(msg).await,
2577 HoprStartProtocol::SsaCommit(client_commit_msg) => {
2578 myself.handle_ssa_commit(pseudonym, client_commit_msg).await
2579 }
2580 HoprStartProtocol::SsaRequest(server_commit_msg) => {
2581 myself.handle_ssa_request(pseudonym, server_commit_msg).await
2582 }
2583 };
2584
2585 if let Err(error) = result {
2586 error!(%error, "failed to process Start protocol message");
2587 }
2588 }
2589 }
2590 )
2591 );
2592
2593 Ok(vec![ah_closure_notifications, ah_session_expiration, ah_start_protocol])
2594 }
2595
2596 /// Check if [`start`](SessionManager::start) has been called and the instance is running.
2597 pub fn is_started(&self) -> bool {
2598 self.session_notifiers.get().is_some()
2599 }
2600
2601 /// Atomically allocates a new [`SessionSlot`] for `session_id` and returns an RAII
2602 /// [`SessionSlotGuard`] for it.
2603 ///
2604 /// Establishing a session involves several fallible steps *after* the slot has been
2605 /// inserted. The returned guard rolls the slot back - tearing the partially
2606 /// established session down via [`close_session`] - unless it is
2607 /// [committed](SessionSlotGuard::commit).
2608 ///
2609 /// The active-sessions gauge is incremented here, atomically with the insertion and
2610 /// the guard creation, precisely so that it is always paired with the guard's
2611 /// rollback decrement (performed through [`close_session`]). This keeps the gauge
2612 /// accurate: it is never decremented for a slot that was not counted in the first
2613 /// place, and every counted slot is decremented exactly once when it leaves the cache.
2614 ///
2615 /// Returns `None` if a slot for `session_id` already exists; in that case nothing is
2616 /// inserted, the gauge is left untouched, and no guard is produced. The atomic `entry`
2617 /// API guarantees that only one concurrent caller can claim the slot for a given
2618 /// pseudonym (avoiding a TOCTOU race), which also rules out loopback sessions onto
2619 /// ourselves.
2620 ///
2621 /// Capacity is enforced by an atomic counter incremented *before* the cache insertion,
2622 /// making it impossible for two concurrent callers (with different session IDs) to both
2623 /// succeed when the cache is already at `maximum_sessions`.
2624 fn allocate_session_slot(&self, session_id: SessionId, slot: SessionSlot) -> Option<SessionSlotGuard<'_>> {
2625 // Try to claim a session slot before touching the cache. `fetch_update` atomically
2626 // increments only if the value is strictly below the limit, preventing two concurrent
2627 // callers from both succeeding when already at capacity.
2628 let counter = &self.active_sessions;
2629 #[allow(clippy::incompatible_msrv)]
2630 let did_reserve = counter
2631 .try_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
2632 (n < self.cfg.maximum_sessions).then_some(n + 1)
2633 })
2634 .is_ok();
2635
2636 if !did_reserve {
2637 return None;
2638 }
2639
2640 let result =
2641 self.sessions
2642 .entry(session_id)
2643 .and_compute_with(|entry: Option<moka::Entry<SessionId, SessionSlot>>| {
2644 if entry.is_none() {
2645 moka::ops::compute::Op::Put(slot)
2646 } else {
2647 // Duplicate key — release the reservation so the counter stays accurate.
2648 counter.fetch_sub(1, Ordering::Relaxed);
2649 moka::ops::compute::Op::Nop
2650 }
2651 });
2652
2653 match result {
2654 moka::ops::compute::CompResult::Inserted(_) => {
2655 // Notify any waiting message handler (e.g. handle_ssa_request) that the slot
2656 // is now available. Drain and signal the senders registered for this SessionId.
2657 if let Some(waiters) = self
2658 .slot_allocated
2659 .lock()
2660 .unwrap_or_else(|e| e.into_inner())
2661 .remove(&session_id)
2662 {
2663 for w in waiters {
2664 let _ = w.send(());
2665 }
2666 }
2667 // take_guard borrows self, so the guard stores the counter clone separately.
2668 Some(SessionSlotGuard::new(&self.sessions, session_id, counter.clone()))
2669 }
2670 _ => None,
2671 }
2672 }
2673
2674 /// Initiates a new outgoing Session to `destination` with the given configuration.
2675 ///
2676 /// If the Session's counterparty does not respond within
2677 /// the [configured](SessionManagerConfig) period,
2678 /// this method returns [`TransportSessionError::Timeout`].
2679 ///
2680 /// It will also fail if the instance has not been [started](SessionManager::start).
2681 pub async fn new_session(
2682 &self,
2683 destination: Address,
2684 target: SessionTarget,
2685 cfg: SessionClientConfig,
2686 ) -> errors::Result<HoprSession> {
2687 self.sessions.run_pending_tasks();
2688 if self.cfg.maximum_sessions <= self.active_sessions.load(Ordering::Relaxed) {
2689 return Err(SessionManagerError::TooManySessions.into());
2690 }
2691
2692 let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
2693
2694 let (tx_initiation_done, rx_initiation_done): (
2695 crossfire::MTx<crossfire::mpsc::One<_>>,
2696 crossfire::AsyncRx<crossfire::mpsc::One<_>>,
2697 ) = crossfire::mpsc::build(crossfire::mpsc::One::new());
2698
2699 let current_ssa_state = Arc::new(OnceLock::new());
2700
2701 let mut additional_data = 0_u64;
2702
2703 // SURB balancer target announcement is encoded in the lower 32-bits of the options word
2704 if !cfg.capabilities.contains(Capability::NoRateControl) {
2705 additional_data |= cfg
2706 .surb_management
2707 .map(|c| c.target_surb_buffer_size)
2708 .unwrap_or(
2709 self.cfg.initial_return_session_egress_rate as u64
2710 * self
2711 .cfg
2712 .minimum_surb_buffer_duration
2713 .max(MIN_SURB_BUFFER_DURATION)
2714 .as_secs(),
2715 )
2716 .min(u32::MAX as u64);
2717 }
2718
2719 // PIX quota parameter announcement is encoded in the upper 32-bits of the options word.
2720 // Run these validations BEFORE reserving the initiation challenge slot so that a
2721 // repeated invalid request cannot exhaust all challenge slots.
2722 if cfg.capabilities.contains(Capability::UsePIX) {
2723 // PIX requires at least 1 intermediate hop on the return path so that PIX
2724 // shares can be encrypted with the first relayer's ticket-challenge solution
2725 // (`HalfKey`) and delivered via return-path SURBs. With 0 intermediate hops
2726 // (a direct Exit→Entry SURB), there is no relayer to provide the challenge
2727 // solution, so shares are never embedded — the ongoing PIX share delivery
2728 // mechanism is dead and the Exit's quota is never replenished.
2729 if cfg.return_path_options.count_hops() == 0 {
2730 return Err(SessionManagerError::Other(anyhow!(
2731 "UsePIX requires at least 1 intermediate hop on the return path, got 0"
2732 ))
2733 .into());
2734 }
2735
2736 // Validate that PIX toolbox is available before advertising UsePIX
2737 let pix_toolbox = self
2738 .pix_toolbox
2739 .get()
2740 .ok_or_else(|| SessionManagerError::Other(anyhow!("UsePIX requested but no PIX toolbox installed")))?;
2741
2742 // The installed generator is what actually produces the shares that go on the wire, so
2743 // it — not the caller — is the source of the announced parameters. Letting a caller name
2744 // them instead would allow a Session to proceed while producing shares the Exit cannot
2745 // reconstruct, so the dimensions are not a per-Session choice at all: `UsePIX` is the
2746 // whole switch, and everything downstream (the per-SSA quota, and so the deposit amount
2747 // reported to the funding strategy in `AgreedSsaQuota`) is derived from what follows.
2748 // The same source for the dimensions and for the curve suite: `HoprPixSpec` is what the
2749 // installed generator is instantiated over, so the announced suite cannot disagree with
2750 // the one that will actually produce the shares.
2751 let gen_cfg = pix_toolbox.share_generator.config();
2752 let params = PixParams::try_from_config::<HoprPixSpec>(gen_cfg)
2753 .map_err(|error| SessionManagerError::Other(anyhow!("invalid PIX dimensions: {error}")))?;
2754
2755 let _ = current_ssa_state.set(SessionSsaState::new(params));
2756 additional_data = params.into_additional_data(additional_data as u32);
2757 }
2758
2759 let (challenge, _) = insert_into_next_slot(
2760 &self.session_initiations,
2761 |ch| {
2762 if let Some(challenge) = ch {
2763 ((challenge + 1) % hopr_api::types::crypto_random::MAX_RANDOM_INTEGER).max(MIN_CHALLENGE)
2764 } else {
2765 hopr_api::types::crypto_random::random_integer(MIN_CHALLENGE, None)
2766 }
2767 },
2768 |_| tx_initiation_done,
2769 Some(self.cfg.maximum_sessions as u64),
2770 )
2771 .ok_or(SessionManagerError::NoChallengeSlots)?; // almost impossible with u64
2772
2773 // Prepare the session initiation message in the Start protocol
2774 trace!(challenge, ?cfg, "initiating session with config");
2775 let start_session_msg = HoprStartProtocol::StartSession(StartInitiation {
2776 challenge,
2777 target,
2778 capabilities: HoprSessionCapabilities(cfg.capabilities),
2779 additional_data: into_initiation_additional_data(additional_data),
2780 });
2781
2782 let pseudonym = cfg.pseudonym.unwrap_or(HoprPseudonym::random());
2783 let forward_routing = DestinationRouting::Forward {
2784 destination: Box::new(destination.into()),
2785 pseudonym: Some(pseudonym), // Session must use a fixed pseudonym already
2786 forward_options: cfg.forward_path_options.clone(),
2787 return_options: cfg.return_path_options.clone().into(),
2788 };
2789
2790 // Send the Session initiation message
2791 info!(challenge, %pseudonym, %destination, "new session request");
2792 send_via_msg_sender(
2793 &mut msg_sender,
2794 forward_routing.clone(),
2795 start_session_msg,
2796 "session request message",
2797 )
2798 .await
2799 .map_err(|error| {
2800 self.session_initiations.remove(&challenge);
2801 TransportSessionError::packet_sending(error)
2802 })?;
2803
2804 // The timeout is given by the number of hops requested
2805 let initiation_timeout: futures_time::time::Duration = initiation_timeout_max_one_way(
2806 self.cfg.initiation_timeout_base,
2807 cfg.forward_path_options.count_hops() + cfg.return_path_options.count_hops() + 2,
2808 )
2809 .into();
2810
2811 // Await session establishment response from the Exit node or timeout
2812
2813 trace!(challenge, "awaiting session establishment");
2814 match rx_initiation_done
2815 .into_stream()
2816 .try_next()
2817 .timeout(initiation_timeout)
2818 .await
2819 {
2820 Ok(Ok(Some(est))) => {
2821 // Session has been established, construct it
2822 let session_id = est.session_id;
2823 debug!(challenge = est.orig_challenge, ?session_id, "started a new session");
2824
2825 let (session_tx, session_rx) =
2826 crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
2827 let (session_rx, session_rx_ah) = hopr_utils::runtime::DropAbortable::new(session_rx.into_stream());
2828
2829 let mut abort_handles = AbortableList::default();
2830 abort_handles.insert(SessionHandles::Ingress, session_rx_ah);
2831
2832 let notifier = self
2833 .session_notifiers
2834 .get()
2835 .map(|(_, notifier)| {
2836 let notifier = notifier.clone();
2837 Box::new(move |session_id: SessionId, reason: ClosureReason| {
2838 let _ = notifier
2839 .try_send((session_id, reason))
2840 .inspect_err(|error| error!(%session_id, %error, "failed to notify session closure"));
2841 })
2842 })
2843 .ok_or(SessionManagerError::NotStarted)?;
2844
2845 // NOTE: the Exit node can have different `max_surb_buffer_size`
2846 // setting on the Session manager, so it does not make sense to cap it here
2847 // with our maximum value.
2848 if let Some(balancer_config) = cfg.surb_management {
2849 let surb_estimator = AtomicSurbFlowEstimator::default();
2850 let surbs_per_keep_alive = KeepAliveMessage::from(session_id).max_surbs()? as u32;
2851
2852 // Sender responsible for keep-alive and Session data will be counting produced SURBs
2853 let surb_estimator_clone = surb_estimator.clone();
2854 let full_surb_scoring_sender =
2855 msg_sender.with(move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
2856 let produced = data.estimate_surbs_with_msg() as u64;
2857 // Count how many SURBs we sent with each packet
2858 surb_estimator_clone
2859 .produced
2860 .fetch_add(produced, std::sync::atomic::Ordering::Relaxed);
2861 #[cfg(feature = "telemetry")]
2862 telemetry::record_session_surb_produced(&session_id, produced);
2863 futures::future::ok::<_, S::Error>((routing, data))
2864 });
2865
2866 let surb_mgmt = Arc::new(BalancerStateValues::from(balancer_config));
2867 // The counterparty's store is the same bounded ring buffer as ours, so its
2868 // capacity bounds what our `produced - consumed` estimate can legitimately
2869 // claim it is holding.
2870 surb_mgmt.set_counterparty_buffer_capacity(self.cfg.maximum_surb_buffer_size as u64);
2871
2872 // For standard Session data we first reduce the number of SURBs we want to produce,
2873 // unless requested to always max them out
2874 let max_surbs_in_packet = cfg.max_surbs_per_data_packet;
2875 let surb_mgmt_for_tx = surb_mgmt.clone();
2876 let reduced_surb_scoring_sender = full_surb_scoring_sender.clone().with(
2877 // NOTE: this is put in-front of the `full_surb_scoring_sender`,
2878 // so that its estimate of SURBs gets automatically updated based on
2879 // the `max_surbs_in_packets` set here.
2880 move |(routing, mut data): (DestinationRouting, ApplicationDataOut)| {
2881 cap_organic_surbs(&mut data, max_surbs_in_packet, &surb_mgmt_for_tx);
2882 futures::future::ok::<_, S::Error>((routing, data))
2883 },
2884 );
2885
2886 // Spawn the SURB-bearing keep alive stream towards the Exit. Suspended until the
2887 // balancer below gives the controller a rate.
2888 let ka_controller = RateController::new(0, Duration::from_secs(1));
2889 let ka_abort_handle = utils::spawn_keep_alive_stream(
2890 session_id,
2891 full_surb_scoring_sender,
2892 forward_routing.clone(),
2893 if self.cfg.surb_target_notify {
2894 SurbNotificationMode::Target
2895 } else {
2896 SurbNotificationMode::DoNotNotify
2897 },
2898 surb_mgmt.clone(),
2899 &ka_controller,
2900 // The Entry's balancer owns this stream outright and there is no second
2901 // producer to arbitrate between, so every packet the rate limiter releases is
2902 // one the balancer asked for.
2903 || true,
2904 );
2905 abort_handles.insert(SessionHandles::KeepAlive, ka_abort_handle);
2906
2907 // Spawn the SURB balancer, which will decide on the initial SURB rate.
2908 debug!(%session_id, ?balancer_config ,"spawning entry SURB balancer");
2909 let balancer = SurbBalancer::new(
2910 session_id,
2911 // The setpoint and output limit is immediately reconfigured by the SurbBalancer
2912 PidBalancerController::from_gains(PidControllerGains::from_env_or_default()),
2913 surb_estimator.clone(),
2914 // Convert the requested SURB rate to packets using the keep-alive's capacity.
2915 SurbControllerWithCorrection(ka_controller, surbs_per_keep_alive),
2916 surb_mgmt.clone(),
2917 );
2918
2919 let (level_stream, balancer_abort_handle) =
2920 balancer.start_control_loop(self.cfg.balancer_sampling_interval);
2921 abort_handles.insert(SessionHandles::Balancer, balancer_abort_handle);
2922
2923 let returned_packets = Arc::new(std::sync::atomic::AtomicU64::new(0));
2924
2925 // Insert the slot before the SURB readiness wait so any echo packets that
2926 // arrive during pre-loading are accepted rather than dropped as unknown.
2927 // Early return from the wait below drops slot_guard uncommitted, which removes
2928 // the slot and calls close_session → abort_all() on the spawned tasks.
2929 let mut slot_guard = self
2930 .allocate_session_slot(
2931 session_id,
2932 SessionSlot {
2933 session_tx,
2934 routing_opts: forward_routing.clone(),
2935 abort_handles: Arc::new(parking_lot::Mutex::new(abort_handles)),
2936 surb_mgmt: surb_mgmt.clone(),
2937 surb_estimator: surb_estimator.clone(),
2938 current_ssa_state,
2939 // Entry side: the Exit is authoritative for the PIX lifecycle, so
2940 // there is no supervisor here and nothing gates egress.
2941 pix_supervisor: Default::default(),
2942 pix_egress_gate: Default::default(),
2943 pix_fill: Default::default(),
2944 returned_packets: returned_packets.clone(),
2945 // Nor does it hold reconstructor state: the live-cycle budget is
2946 // charged by the side that reconstructs.
2947 cycle_budget: None,
2948 },
2949 )
2950 .ok_or_else(|| {
2951 // Session already exists; it means it is most likely a loopback attempt
2952 error!(%session_id, "session already exists - loopback attempt");
2953 SessionManagerError::Loopback
2954 })?;
2955
2956 // Prevent the slot from being evicted by time_to_idle while SURBs are
2957 // pre-loading. Each `get()` call resets the idle timer; the task is
2958 // aborted as soon as the readiness wait resolves.
2959 let sessions_keepalive = self.sessions.clone();
2960 let touch_period = (self.cfg.idle_timeout / 2).max(self.cfg.min_session_touch_period);
2961 let slot_keepalive = hopr_utils::runtime::prelude::spawn(async move {
2962 loop {
2963 hopr_utils::runtime::prelude::sleep(touch_period).await;
2964 let _ = sessions_keepalive.get(&session_id);
2965 }
2966 });
2967
2968 // TODO: consider making this interactive = other party reports the exact level periodically
2969 let wait_result = level_stream
2970 .skip_while(|current_level| {
2971 futures::future::ready(*current_level < balancer_config.target_surb_buffer_size / 2)
2972 })
2973 .next()
2974 .timeout(futures_time::time::Duration::from(SESSION_READINESS_TIMEOUT))
2975 .await;
2976 slot_keepalive.abort();
2977 match wait_result {
2978 Ok(Some(surb_level)) => {
2979 info!(%session_id, surb_level, "session is ready");
2980 }
2981 Ok(None) => {
2982 return Err(
2983 SessionManagerError::other(anyhow!("surb balancer was cancelled prematurely")).into(),
2984 );
2985 }
2986 Err(_) => {
2987 warn!(%session_id, "session didn't reach target SURB buffer size in time");
2988 }
2989 }
2990
2991 #[cfg(all(feature = "telemetry", not(test)))]
2992 METRIC_NUM_INITIATED_SESSIONS.increment();
2993
2994 let surb_estimator_for_rx = surb_estimator.clone();
2995 let session = HoprSession::new_with_surb_state(
2996 session_id,
2997 forward_routing,
2998 session_config_with(&self.cfg, cfg.capabilities, cfg.max_frames_behind_gap),
2999 (
3000 reduced_surb_scoring_sender,
3001 session_rx.inspect(move |_| {
3002 // The Exit's SURB signals are recorded in `dispatch_message`, upstream
3003 // of this: they override the organic SURB gate, and a packet dropped
3004 // before it reaches here would take the override with it.
3005
3006 // Received packets = SURB consumption estimate
3007 // The received packets always consume a single SURB.
3008 surb_estimator_for_rx
3009 .consumed
3010 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3011 // Same event, separate counter, on purpose — see `returned_packets`.
3012 returned_packets.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3013 #[cfg(feature = "telemetry")]
3014 telemetry::record_session_surb_consumed(&session_id, 1);
3015 }),
3016 ),
3017 Some(notifier),
3018 // Entry (sending) side: give flow control the SURB balancer state as its
3019 // anti-grief down-only ceiling, and the client's opt-in flow-control config.
3020 Some(surb_mgmt.clone()),
3021 cfg.flow_control,
3022 )?;
3023
3024 #[cfg(feature = "telemetry")]
3025 initialize_session_telemetry(
3026 session_id,
3027 &self.cfg,
3028 cfg.capabilities,
3029 Some(&surb_estimator),
3030 Some(&surb_mgmt),
3031 );
3032
3033 slot_guard.commit();
3034 Ok(session)
3035 } else {
3036 // Routine for short-lived sessions (health checks, bridges); not a fault.
3037 tracing::debug!(%session_id, "session ready without SURB balancing");
3038
3039 // Counted here too, unlike `surb_estimator`: the PIX successor gate reads this,
3040 // and a knob that binds on some Sessions and not others is how a deposit gate
3041 // silently becomes a gate that never opens.
3042 let returned_packets = Arc::new(std::sync::atomic::AtomicU64::new(0));
3043 let returned_packets_for_rx = returned_packets.clone();
3044
3045 // Disabled SURB management: a default state has a zero target, which reads as
3046 // `is_disabled()` and so caps organic SURBs at one per packet — this branch's
3047 // behaviour, expressed through the same policy the balancing branch uses rather
3048 // than restated as a literal below.
3049 let surb_mgmt: Arc<BalancerStateValues> = Default::default();
3050
3051 // Insert the slot and obtain a guard that rolls it back if any
3052 // subsequent setup step fails.
3053 let mut slot_guard = self
3054 .allocate_session_slot(
3055 session_id,
3056 SessionSlot {
3057 session_tx,
3058 routing_opts: forward_routing.clone(),
3059 abort_handles: Arc::new(parking_lot::Mutex::new(abort_handles)),
3060 surb_mgmt: surb_mgmt.clone(),
3061 surb_estimator: Default::default(), // No SURB estimator needed
3062 current_ssa_state,
3063 // Entry side: the Exit is authoritative for the PIX lifecycle.
3064 pix_supervisor: Default::default(),
3065 pix_egress_gate: Default::default(),
3066 pix_fill: Default::default(),
3067 returned_packets,
3068 // Nor does it hold reconstructor state: the live-cycle budget is
3069 // charged by the side that reconstructs.
3070 cycle_budget: None,
3071 },
3072 )
3073 .ok_or_else(|| {
3074 // Session already exists; it means it is most likely a loopback attempt
3075 error!(%session_id, "session already exists - loopback attempt");
3076 SessionManagerError::Loopback
3077 })?;
3078
3079 #[cfg(all(feature = "telemetry", not(test)))]
3080 METRIC_NUM_INITIATED_SESSIONS.increment();
3081
3082 // For standard Session data we first reduce the number of SURBs we want to produce,
3083 // unless requested to always max them out
3084 let max_surbs_in_packet = cfg.max_surbs_per_data_packet;
3085 let surb_mgmt_for_tx = surb_mgmt.clone();
3086 let reduced_surb_sender =
3087 msg_sender.with(move |(routing, mut data): (DestinationRouting, ApplicationDataOut)| {
3088 cap_organic_surbs(&mut data, max_surbs_in_packet, &surb_mgmt_for_tx);
3089 futures::future::ok::<_, S::Error>((routing, data))
3090 });
3091
3092 let session = HoprSession::new(
3093 session_id,
3094 forward_routing,
3095 session_config_with(&self.cfg, cfg.capabilities, cfg.max_frames_behind_gap),
3096 (
3097 reduced_surb_sender,
3098 session_rx.inspect(move |_| {
3099 returned_packets_for_rx.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3100 }),
3101 ),
3102 Some(notifier),
3103 )?;
3104
3105 #[cfg(feature = "telemetry")]
3106 initialize_session_telemetry(session_id, &self.cfg, cfg.capabilities, None, None);
3107
3108 slot_guard.commit();
3109 Ok(session)
3110 }
3111 }
3112 Ok(Ok(None)) => {
3113 self.session_initiations.remove(&challenge);
3114 Err(SessionManagerError::other(anyhow!(
3115 "internal error: sender has been closed without completing the session establishment"
3116 ))
3117 .into())
3118 }
3119 Ok(Err(error)) => {
3120 // The other side did not allow us to establish a session
3121 let challenge = match error.identifier {
3122 ErrorIdentifier::Challenge(c) => c,
3123 ErrorIdentifier::SessionId(_) => {
3124 // This arm should only ever receive pre-establishment errors.
3125 // Log the id if it matters for debugging.
3126 0
3127 }
3128 };
3129 error!(
3130 %challenge, ?error,
3131 "the other party rejected the session initiation with error"
3132 );
3133 Err(TransportSessionError::Rejected(error.reason))
3134 }
3135 Err(_) => {
3136 // Timeout waiting for a session establishment
3137 error!(challenge, "session initiation attempt timed out");
3138
3139 #[cfg(all(feature = "telemetry", not(test)))]
3140 METRIC_RECEIVED_SESSION_ERRS.increment(&["timeout"]);
3141
3142 self.session_initiations.remove(&challenge);
3143 Err(TransportSessionError::Timeout)
3144 }
3145 }
3146 }
3147
3148 /// Sends a keep-alive packet with the given [`SessionId`].
3149 ///
3150 /// This currently "fires & forgets" and does not expect nor await any "pong" response.
3151 pub async fn ping_session(&self, id: &SessionId) -> errors::Result<()> {
3152 if let Some(session_data) = self.sessions.get(id) {
3153 trace!(session_id = ?id, "pinging manually session");
3154 let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3155 send_via_msg_sender(
3156 &mut msg_sender,
3157 session_data.routing_opts.clone(),
3158 HoprStartProtocol::KeepAlive((*id).into()),
3159 "session ping message",
3160 )
3161 .await
3162 .map_err(TransportSessionError::packet_sending)
3163 } else {
3164 Err(SessionManagerError::NonExistingSession.into())
3165 }
3166 }
3167
3168 /// Asks the deposit pool for the data to attach to a batch of SSAs, and collects its answers.
3169 async fn request_deposit_data(
3170 pix_toolbox: &PixToolbox,
3171 session_id: SessionId,
3172 ssa_indices: &[SsaIndex],
3173 ) -> errors::Result<HoprPixDepositData> {
3174 let deposit_ids = ssa_indices
3175 .iter()
3176 .map(|&ssa_index| PixAddressId::new(&session_id, ssa_index))
3177 .collect::<Vec<_>>();
3178
3179 let (deposit_data_created, deposit_data_rx) = futures::channel::mpsc::channel(ssa_indices.len().max(1));
3180
3181 if pix_toolbox
3182 .pix_events
3183 .try_send(HoprSessionOutPixEvent::DepositDataRequest(PixDepositDataRequest {
3184 deposit_ids,
3185 deposit_data_created,
3186 }))
3187 .is_err()
3188 {
3189 return Err(SessionManagerError::MissingDepositData(format!(
3190 "session {session_id} has no listener for the deposit data request"
3191 ))
3192 .into());
3193 }
3194
3195 let mut outstanding = ssa_indices
3196 .iter()
3197 .map(|&ssa_index| PixAddressId::new(&session_id, ssa_index))
3198 .collect::<std::collections::HashSet<_>>();
3199 let requested = outstanding.len();
3200
3201 let mut deposit_data = HoprPixDepositData::with_capacity(requested);
3202 {
3203 let replies = deposit_data_rx
3204 .take(2 * requested)
3205 .take_until(futures_time::task::sleep(futures_time::time::Duration::from(
3206 DEPOSIT_DATA_REQUEST_TIMEOUT,
3207 )));
3208 futures::pin_mut!(replies);
3209 while let Some(entry) = replies.next().await {
3210 outstanding.remove(&entry.id);
3211 deposit_data.push(entry);
3212 if outstanding.is_empty() {
3213 break;
3214 }
3215 }
3216 }
3217
3218 // Counted here rather than left to `deposit_data_for_batch`, which sees the same shortfall
3219 // but cannot tell a pool that went quiet from one that answered about the wrong SSAs. Both
3220 // are fatal; only one of them is fixed by making the pool faster.
3221 if !outstanding.is_empty() {
3222 return Err(SessionManagerError::MissingDepositData(format!(
3223 "session {session_id} got {} of {requested} requested deposit data entries within \
3224 {DEPOSIT_DATA_REQUEST_TIMEOUT:?} ({} replies read in total)",
3225 requested - outstanding.len(),
3226 deposit_data.len(),
3227 ))
3228 .into());
3229 }
3230
3231 Ok(deposit_data)
3232 }
3233
3234 /// Spawns the task that carries out one Session's supervisor actions.
3235 ///
3236 /// The supervisor decides; this executes. Keeping the two apart is what lets the state machine
3237 /// be pure and exhaustively tested, and it means every side effect a PIX Session can have is
3238 /// visible in one `match`.
3239 ///
3240 /// The task owns an [`SsaCommitmentGuard`] per SSA it has requested and one Session retirement
3241 /// scope. Terminal actions retire through the frontier before releasing their guard; aborting
3242 /// the task drops every remaining guard and then the Session's compact frontier. No state grows
3243 /// with the number of cycles the Session completed.
3244 fn spawn_pix_action_driver(
3245 &self,
3246 session_id: SessionId,
3247 slot: &SessionSlot,
3248 action_rx: ActionRx,
3249 reply_routing: DestinationRouting,
3250 ) -> hopr_utils::runtime::AbortHandle {
3251 let myself = self.clone();
3252 let gate = slot
3253 .pix_egress_gate
3254 .get()
3255 .cloned()
3256 .expect("the gate is installed before the driver is spawned");
3257 let share_processor = self
3258 .pix_toolbox
3259 .get()
3260 .expect("the PIX toolbox is installed before a PIX action driver is spawned")
3261 .share_processor
3262 .clone();
3263 let retirement_scope = share_processor.begin_retirement_scope(session_id);
3264
3265 hopr_utils::spawn_as_abortable!(async move {
3266 let _retirement_scope = retirement_scope;
3267 // The vector owns each registration until a terminal action explicitly retires it or
3268 // dropping this future releases it during whole-Session teardown.
3269 let mut owned_ssas: Vec<SsaCommitmentGuard<HoprPixSpec>> = Vec::new();
3270
3271 let close_reason = loop {
3272 let Ok(action) = action_rx.recv().await else {
3273 // The worker is gone without having said why, which is a failure of the
3274 // supervisor itself rather than of the Session it was watching.
3275 break Some(SessionPixCloseReason::SupervisorUnavailable);
3276 };
3277
3278 match action {
3279 SessionPixAction::RequestSsa { ssa_ids, params } => {
3280 let Some(slot) = myself.sessions.get(&session_id) else {
3281 // The Session went away underneath us; report the failure so the
3282 // supervisor stops waiting on an action that can never complete.
3283 myself
3284 .report_ssa_request(
3285 &session_id,
3286 None,
3287 SessionPixAction::RequestSsa { ssa_ids, params },
3288 false,
3289 )
3290 .await;
3291 continue;
3292 };
3293 match myself.send_ssa_request(session_id, &slot, &ssa_ids, params).await {
3294 Ok(guards) => {
3295 owned_ssas.extend(guards);
3296 // One confirmation per index: the supervisor arms each cycle's
3297 // commitment deadline on its own `SsaRequestSent`, and they were all
3298 // put on the wire by the one send that just succeeded.
3299 if let Some(supervisor) = slot.pix_supervisor.get() {
3300 for ssa_id in &ssa_ids {
3301 if supervisor
3302 .send_event(SessionPixEvent::SsaRequestSent(*ssa_id))
3303 .await
3304 .is_err()
3305 {
3306 error!(%session_id, "pix supervisor stopped accepting events");
3307 break;
3308 }
3309 }
3310 }
3311 }
3312 Err(error) => {
3313 // Every guard was dropped by the early return inside
3314 // `send_ssa_request`, so the whole batch of indices is released — none
3315 // of them is left registered for a cycle that will never be asked for.
3316 error!(
3317 %session_id, %error, batch_size = ssa_ids.len(),
3318 "failed to send ssa request"
3319 );
3320
3321 // A pool that could not supply deposit data ends the Session here
3322 // rather than reporting the failure onwards. The other failures are
3323 // transient or already self-correcting, and the supervisor is the
3324 // right judge of them; this one is neither. The data only ever
3325 // travels in the `SsaRequest` that carries the commitments, so it
3326 // cannot be supplied later, and the pool will not answer a retry it
3327 // did not answer the first time.
3328 //
3329 // Named rather than folded into the supervisor's `PixFailure`,
3330 // because it points at this node's own pool while every reason the
3331 // supervisor has points at the peer — and the Entry is told with a
3332 // reason it can act on rather than the generic one.
3333 if matches!(
3334 error,
3335 TransportSessionError::Manager(SessionManagerError::MissingDepositData(_))
3336 ) {
3337 myself.stop_pix_fill(&session_id);
3338 gate.poison();
3339 owned_ssas.clear();
3340 myself
3341 .notify_session_error(
3342 session_id,
3343 reply_routing.clone(),
3344 StartErrorReason::UnacceptablePixParams,
3345 "session error due to missing deposit data",
3346 )
3347 .await;
3348 myself.close_session_with_reason(&session_id, ClosureReason::MissingDepositData);
3349 return;
3350 }
3351
3352 if let Some(supervisor) = slot.pix_supervisor.get()
3353 && supervisor
3354 .send_action_result(
3355 // Only the discriminant and the ids are read on the
3356 // failure path, so the dimensions are echoed back
3357 // rather than zeroed — `PixParams` has no zero value,
3358 // and inventing one would be a lie about what was sent.
3359 SessionPixAction::RequestSsa { ssa_ids, params },
3360 false,
3361 )
3362 .await
3363 .is_err()
3364 {
3365 error!(%session_id, "pix supervisor stopped accepting results");
3366 }
3367 }
3368 }
3369 }
3370 SessionPixAction::RequestCommitmentRetransmission { ssa_id, params } => {
3371 // Failure is logged and dropped rather than reported back. Unlike
3372 // `RequestSsa` this action is not a step the Session depends on: it repeats
3373 // on its own interval, and the commitment deadline is still the backstop, so
3374 // a dropped ask costs one interval rather than the Session.
3375 if let Some(slot) = myself.sessions.get(&session_id)
3376 && let Err(error) = myself
3377 .send_commitment_retransmission_request(session_id, &slot, ssa_id, params)
3378 .await
3379 {
3380 error!(%session_id, %ssa_id, %error, "failed to re-request ssa commitment parts");
3381 }
3382 }
3383 SessionPixAction::ReleaseService => {
3384 #[cfg(feature = "telemetry")]
3385 crate::telemetry::set_pix_gate_mode(&session_id, true);
3386 }
3387 SessionPixAction::WithholdService => {
3388 #[cfg(feature = "telemetry")]
3389 crate::telemetry::set_pix_gate_mode(&session_id, false);
3390 }
3391 // Gate control is applied synchronously by the supervisor worker. The action
3392 // reaches this I/O driver only to preserve observability and ordering.
3393 SessionPixAction::ProgressNotification => {}
3394 SessionPixAction::SetFillRate(rate) => {
3395 // Looked up per action rather than captured, because the Session may already
3396 // be gone: the supervisor plans against state it holds itself, and the slot
3397 // can be evicted underneath it. A missing slot needs no rate — its stream is
3398 // already aborted.
3399 if let Some(slot) = myself.sessions.get(&session_id)
3400 && let Some(fill) = slot.pix_fill.get()
3401 {
3402 fill.set_fill(rate);
3403 // Inside the guard for the same reason the lookup is: `close_session`
3404 // removes this Session's metric state and *then* aborts the tasks, and
3405 // the abort is not synchronous, so a queued `SetFillRate` can still be
3406 // processed afterwards. Setting the gauge there would re-create the
3407 // `session_id` label series with nothing left to remove it again.
3408 #[cfg(feature = "telemetry")]
3409 crate::telemetry::set_pix_fill_rate(&session_id, rate.as_packets_per_sec());
3410 }
3411 }
3412 SessionPixAction::RetireSsa(ssa_id) => {
3413 share_processor.retire_ssa(ssa_id);
3414 owned_ssas.retain(|guard| guard.ssa_id() != Some(&ssa_id));
3415 if let Some(slot) = myself.sessions.get(&session_id) {
3416 slot.abort_handles
3417 .lock()
3418 .abort_one(&SessionHandles::PixDepositObserver(ssa_id.ssa_index().get()));
3419 }
3420 }
3421 SessionPixAction::Close(reason) => break Some(reason),
3422 }
3423 };
3424
3425 let Some(reason) = close_reason else { return };
3426
3427 // Read before `stop_pix_fill`, which silences the stream either way. Keyed on the drain
3428 // rather than on the reason, which is a strict superset of it: a *stalled* drain closes
3429 // with `RecoveryIdle` or `RecoveryDeadline`, and that is likewise the ordinary end of a
3430 // Session an operator has already closed rather than something to page anyone about.
3431 let draining = myself
3432 .sessions
3433 .get(&session_id)
3434 .is_some_and(|slot| slot.pix_fill.get().is_some_and(|fill| fill.is_draining()));
3435 if draining {
3436 info!(%session_id, %reason, "pix supervisor ended the post-close SURB drain");
3437 } else {
3438 error!(%session_id, %reason, "pix supervisor closed the session");
3439 }
3440
3441 // Unblock anything parked on the gate before tearing down: the supervisor that would
3442 // have woken it is the thing that just stopped. Fill is silenced alongside it, and for a
3443 // sharper reason — the gate holds packets back, while fill would go on *originating*
3444 // them, return-routed to a pseudonym whose SURBs are about to be gone.
3445 myself.stop_pix_fill(&session_id);
3446 gate.poison();
3447 owned_ssas.clear();
3448
3449 #[cfg(feature = "telemetry")]
3450 crate::telemetry::pix::record_pix_closure(reason);
3451
3452 // Return this Session's share of the node-level aggregates now, rather than leaving it
3453 // to `close_session` below. The notification between here and there is a network send
3454 // with its own timeout, and for the whole of it the Session is closed — its supervisor
3455 // has stopped and its gate is poisoned — while `hopr_pix_sessions_active` would still
3456 // be counting it. Idempotent, so `close_session` remains the backstop for every other
3457 // path into it.
3458 if let Some(slot) = myself.sessions.get(&session_id)
3459 && let Some(supervisor) = slot.pix_supervisor.get()
3460 {
3461 supervisor.telemetry.release();
3462 }
3463
3464 // Tell the Entry, so it can drop its side rather than wait out its own timeout. The
3465 // Session is closed either way, so a send failure here changes nothing.
3466 //
3467 // Except after a drain, where the Session the report would be about is already gone and
3468 // the report is return-routed: sending it would spend a SURB out of a buffer that may
3469 // already be empty, which is precisely the origination this whole path exists to avoid.
3470 // It would also be new behaviour rather than preserved behaviour, since an Exit-side
3471 // close sends the Entry nothing today — and `draining` is set before the supervisor's
3472 // verdict, so this covers a refused drain as well as a completed one.
3473 if !draining {
3474 myself.notify_pix_failure(session_id, reply_routing).await;
3475 }
3476
3477 if let Some(slot) = myself.sessions.remove(&session_id) {
3478 myself.active_sessions.fetch_sub(1, Ordering::Relaxed);
3479 close_session(session_id, slot, ClosureReason::PixFailure);
3480 }
3481 })
3482 }
3483
3484 /// Starts the Exit's single Exit → Entry keep-alive stream and installs the handle that steers it.
3485 ///
3486 /// Called for every PIX Session, and for any Session with a configured
3487 /// `surb_balance_notify_period` — the SURB-level notification and PIX fill are the same packet
3488 /// sent for different reasons, so they share one stream and one rate controller. See
3489 /// [`PixFillControl`].
3490 ///
3491 /// Returns without spawning anything when neither producer applies, which is an Exit Session that
3492 /// is not PIX on a node that has switched the notification off. Such a Session originates nothing,
3493 /// which is what it did before this existed.
3494 ///
3495 /// Deliberately not passed through the PIX egress gate. These packets carry no payload; gating
3496 /// them would let an exhausted predeposit budget silence the very signal the Entry needs in order
3497 /// to fund the Session, turning a stall into a teardown. The bound that does apply is
3498 /// [`PixFillControl::admit`]'s SURB reserve, which is about this node's own ability to send at
3499 /// all rather than about what the peer has paid for.
3500 ///
3501 /// `announced_surb_target` is the SURB buffer target the Entry asked for, and it is what that
3502 /// reserve is derived against — see [`effective_surb_reserve`]. It has to be, because the
3503 /// configured reserve is an absolute count while the buffer it is measured against is the Entry's
3504 /// choice; a Session whose Entry asks for a shallower buffer than the configured reserve could
3505 /// otherwise never be filled at all.
3506 fn spawn_exit_keep_alive(
3507 &self,
3508 session_id: SessionId,
3509 slot: &SessionSlot,
3510 msg_sender: S,
3511 is_pix: bool,
3512 announced_surb_target: u64,
3513 ) {
3514 let notify_period = self.cfg.surb_balance_notify_period;
3515 if notify_period.is_none() && !is_pix {
3516 return;
3517 }
3518
3519 let configured_reserve = self.cfg.pix_config.supervision.fill.min_surb_reserve;
3520 let min_surb_reserve = effective_surb_reserve(configured_reserve, announced_surb_target);
3521
3522 // Created here and shared: the sink wrapper below has to hold the same control the stream is
3523 // rate-limited by, so that it can tell a notification packet from a fill packet.
3524 let controller = RateController::new(0, Duration::from_secs(1));
3525 let control = Arc::new(PixFillControl::new(
3526 session_id,
3527 controller.clone(),
3528 notify_period,
3529 min_surb_reserve,
3530 slot.surb_estimator.clone(),
3531 ));
3532
3533 let control_for_admission = control.clone();
3534 let surb_estimator = slot.surb_estimator.clone();
3535 let ka_abort_handle = utils::spawn_keep_alive_stream(
3536 session_id,
3537 // Consumption is counted in the sink rather than at the rate limiter, so it counts what
3538 // was actually sent: a packet the admission predicate dropped consumed no SURB, and
3539 // counting it would walk the Exit's own level estimate down until it starved itself of
3540 // the very reserve that dropped it.
3541 msg_sender.with(move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
3542 surb_estimator
3543 .consumed
3544 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3545 #[cfg(feature = "telemetry")]
3546 telemetry::record_session_surb_consumed(&session_id, 1);
3547 futures::future::ok::<_, S::Error>((routing, data))
3548 }),
3549 slot.routing_opts.clone(),
3550 SurbNotificationMode::Level(slot.surb_estimator.clone()),
3551 slot.surb_mgmt.clone(),
3552 &controller,
3553 move || control_for_admission.admit(),
3554 );
3555
3556 slot.abort_handles
3557 .lock()
3558 .insert(SessionHandles::KeepAlive, ka_abort_handle);
3559 let _ = slot.pix_fill.set(control.clone());
3560
3561 if let Some(period) = notify_period {
3562 // Delayed by one period, as it always was: a Session that closes immediately should not
3563 // have sent a level report about SURBs it never used. Fill is not held behind this — the
3564 // supervisor's first rate applies the moment it is planned.
3565 //
3566 // Registered so `close_session` can abort it. It is a one-shot, but it holds a clone of
3567 // the control for the whole period, and nothing bounds how long an operator may make that.
3568 let notify_delay = hopr_utils::spawn_as_abortable!(async move {
3569 hopr_utils::runtime::prelude::sleep(period).await;
3570 control.start_notify();
3571 });
3572 slot.abort_handles
3573 .lock()
3574 .insert(SessionHandles::SurbNotifyDelay, notify_delay);
3575 }
3576
3577 // An order of magnitude is the point at which the operator's intent and the Session's shape
3578 // have stopped being about the same deployment. A configured reserve of `R` is a reserve
3579 // sized for a buffer of `4R`; the derivation only lowers it tenfold once the Entry announces
3580 // under `4R / 10`, so this fires exactly on a Session whose buffer is an order of magnitude
3581 // shallower than the one this node was configured against. Fill still runs — a derived
3582 // reserve is strictly better than one that refuses every packet forever — but it is worth
3583 // saying out loud once, when the stream starts, rather than once per withheld packet.
3584 if min_surb_reserve.saturating_mul(10) < configured_reserve {
3585 warn!(
3586 %session_id,
3587 configured = configured_reserve,
3588 effective = min_surb_reserve,
3589 announced_surb_target,
3590 "the PIX fill SURB reserve was lowered by more than an order of magnitude to fit the SURB buffer \
3591 target the entry announced for this session"
3592 );
3593 }
3594
3595 debug!(
3596 %session_id, ?notify_period, is_pix,
3597 surb_reserve = min_surb_reserve,
3598 announced_surb_target,
3599 "started the exit keep-alive stream"
3600 );
3601 }
3602
3603 /// Silences a Session's PIX fill, leaving its SURB-level notification alone.
3604 ///
3605 /// Called on every path that is about to tear a Session down. `close_session` aborts the stream
3606 /// outright, so this is not what stops it — it is what stops the *extra* traffic in the window
3607 /// between a supervisor deciding the Session is over and the teardown reaching the stream. Every
3608 /// packet originated in that window is return-routed to a pseudonym whose SURBs are gone, and one
3609 /// packet the routing resolver cannot resolve holds up every packet this node originates.
3610 fn stop_pix_fill(&self, session_id: &SessionId) {
3611 if let Some(slot) = self.sessions.get(session_id)
3612 && let Some(fill) = slot.pix_fill.get()
3613 {
3614 fill.set_fill(FillRate::ZERO);
3615 }
3616 }
3617
3618 /// Reports an action outcome to a Session's supervisor, if it still has one.
3619 async fn report_ssa_request(
3620 &self,
3621 session_id: &SessionId,
3622 slot: Option<&SessionSlot>,
3623 action: SessionPixAction,
3624 ok: bool,
3625 ) {
3626 let slot = match slot {
3627 Some(slot) => Some(slot.clone()),
3628 None => self.sessions.get(session_id),
3629 };
3630 if let Some(slot) = slot
3631 && let Some(supervisor) = slot.pix_supervisor.get()
3632 && supervisor.send_action_result(action, ok).await.is_err()
3633 {
3634 error!(%session_id, "pix supervisor stopped accepting results");
3635 }
3636 }
3637
3638 /// Best-effort `SessionError` for an already-established Session, identified by its `SessionId`.
3639 ///
3640 /// Best-effort in the strict sense: the outcome is logged and discarded. Every caller is on a path
3641 /// that has already decided the Session is over, so there is nothing a failed send could change —
3642 /// and the peer has a deadline of its own as the backstop either way. What the notice buys is
3643 /// promptness and attribution: the peer tears down in about a round trip, with the reason in its
3644 /// log, instead of waiting out a timer that names the timer rather than the cause.
3645 ///
3646 /// The peer's `handle_session_error` closes the Session on a `SessionId`-identified error and sends
3647 /// nothing back, so there is no error exchange to loop.
3648 async fn notify_session_error(
3649 &self,
3650 session_id: SessionId,
3651 routing: DestinationRouting,
3652 reason: StartErrorReason,
3653 context: &'static str,
3654 ) {
3655 let Some(mut msg_sender) = self.msg_sender.get().cloned() else {
3656 warn!(%session_id, %reason, context, "cannot send session error - manager not started");
3657 return;
3658 };
3659 match send_via_msg_sender(
3660 &mut msg_sender,
3661 routing,
3662 HoprStartProtocol::SessionError(StartErrorType {
3663 identifier: ErrorIdentifier::SessionId(session_id),
3664 reason,
3665 }),
3666 context,
3667 )
3668 .await
3669 {
3670 Ok(()) => {
3671 #[cfg(all(feature = "telemetry", not(test)))]
3672 METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
3673 }
3674 Err(error) => warn!(%session_id, %reason, %error, context, "failed to send session error"),
3675 }
3676 }
3677
3678 /// Best-effort notice to the Entry that this Session died for PIX reasons.
3679 async fn notify_pix_failure(&self, session_id: SessionId, reply_routing: DestinationRouting) {
3680 self.notify_session_error(
3681 session_id,
3682 reply_routing,
3683 StartErrorReason::Unknown,
3684 "session error after pix failure",
3685 )
3686 .await;
3687 }
3688
3689 /// Registers an Exit commitment for every index in `ssa_indices` and asks the Entry to commit to
3690 /// the matching SSAs — all in a single [`SsaServerCommitmentMessage`].
3691 ///
3692 /// Driven by the supervisor's [`RequestSsa`](SessionPixAction::RequestSsa) action, which is what
3693 /// allocates the indices and fixes the dimensions — this only carries it out. Taking them as
3694 /// arguments rather than re-reading them from the slot keeps one source of truth for what was
3695 /// negotiated: the commitments registered here and the parameters the message advertises cannot
3696 /// disagree with what the supervisor is timing.
3697 ///
3698 /// One message for the whole batch, and one `params` field covering all of it, which is correct:
3699 /// every SSA in a Session uses the same negotiated dimensions. The Entry enforces its own ceiling
3700 /// on how many it will accept ([`SessionManagerConfig::max_ssas_per_ssa_request`]) and refuses an
3701 /// over-cap batch in full, so a batch larger than the peer allows loses the Session — see
3702 /// [`SupervisorConfig::ssas_per_request`](crate::SupervisorConfig::ssas_per_request).
3703 ///
3704 /// Installs no deadline of its own: every timeout that used to be armed here now belongs to the
3705 /// supervisor, which can see the whole cycle rather than just this one step.
3706 ///
3707 /// The returned [`SsaCommitmentGuard`]s own their registrations until the caller transfers or drops
3708 /// them. Handing them out rather than retaining them here is what makes the failure path safe:
3709 /// this function has fallible steps after the registrations exist — including partway through the
3710 /// batch — and an early return from any of them releases every one of them. That matters more for
3711 /// a batch than for a single SSA: without it one failed send would strand every index it had
3712 /// registered, and since the supervisor does not reuse an index, the Session could never recover.
3713 async fn send_ssa_request(
3714 &self,
3715 session_id: SessionId,
3716 slot: &SessionSlot,
3717 ssa_ids: &[SsaId<HoprPseudonym>],
3718 params: PixParams,
3719 ) -> errors::Result<Vec<SsaCommitmentGuard<HoprPixSpec>>> {
3720 let Some(first_ssa_id) = ssa_ids.first().copied() else {
3721 return Err(SessionManagerError::Other(anyhow!("ssa request with no indices")).into());
3722 };
3723
3724 let pix_toolbox = self.pix_toolbox.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3725 // Clone kept on this task: `pix_toolbox` itself is moved into the blocking commitment task,
3726 // while the deposit-data request below needs it here.
3727 let pix_toolbox_local = pix_toolbox.clone();
3728 let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3729
3730 // One blocking task for the whole batch rather than one per SSA: each commitment is a single
3731 // random scalar and one generator multiplication, so per-task overhead would dominate.
3732 let ids = ssa_ids.to_vec();
3733 let (exit_commitments, guards) = hopr_utils::parallelize::cpu::spawn_blocking(
3734 move || {
3735 let mut commitments = Vec::with_capacity(ids.len());
3736 let mut guards = Vec::with_capacity(ids.len());
3737 for ssa_id in ids {
3738 let (commitment, guard) = pix_toolbox
3739 .share_processor
3740 .new_guarded_exit_commitment(ssa_id, params)?;
3741 commitments.push((ssa_id.ssa_index(), HoprPixGroupElement(commitment.to_bytes())));
3742 guards.push(guard);
3743 }
3744 Ok::<_, hopr_protocol_pix::errors::PixError<HoprPseudonym>>((commitments, guards))
3745 },
3746 "server_ssa_commitment",
3747 )
3748 .await
3749 .map_err(SessionManagerError::other)?
3750 .map_err(SessionManagerError::PixError)?;
3751
3752 info!(
3753 %session_id, batch_size = ssa_ids.len(), %params, %first_ssa_id,
3754 "generated exit commitments for the SSA batch"
3755 );
3756
3757 // Ask the deposit pool for the data to attach to this batch, and keep what it produced: the
3758 // Exit needs it twice. Once now, to put on the wire, and once per SSA later, to hand back to
3759 // the pool with the deposit address in `DepositNeeded` — by which point the batch it came
3760 // from is long gone.
3761 //
3762 // A pool that does not answer fails the batch here, which drops `guards` and releases every
3763 // registration it made. That is the only safe direction: deposit data reaches the Entry in
3764 // this message or not at all, so a batch sent without it buys service the Entry can never
3765 // pay for.
3766 let ssa_indices = ssa_ids.iter().map(|ssa_id| ssa_id.ssa_index()).collect::<Vec<_>>();
3767 let deposit_data = Self::request_deposit_data(&pix_toolbox_local, session_id, &ssa_indices).await?;
3768
3769 if !self.sessions.contains_key(&session_id) {
3770 return Err(SessionManagerError::NonExistingSession.into());
3771 }
3772
3773 let deposit_data = deposit_data_for_batch(&session_id, &ssa_indices, deposit_data)?;
3774 if let Some(ssa_state) = slot.current_ssa_state.get() {
3775 ssa_state.remember_sent_deposit_data(&deposit_data);
3776 }
3777
3778 // Construct and send the Exit SSA commitment request message.
3779 // The parameters were previously verified to be acceptable.
3780 let data = HoprStartProtocol::SsaRequest(SsaServerCommitmentMessage::new(
3781 session_id,
3782 params,
3783 exit_commitments,
3784 deposit_data,
3785 ));
3786
3787 send_via_msg_sender(
3788 &mut msg_sender,
3789 slot.routing_opts.clone(),
3790 data,
3791 "session SSA commitment request message",
3792 )
3793 .await
3794 .map_err(TransportSessionError::packet_sending)?;
3795
3796 Ok(guards)
3797 }
3798
3799 /// Asks the Entry to re-send the parts of one SSA's commitment that never arrived.
3800 ///
3801 /// The Entry's answer to an `SsaRequest` is hundreds of packets and the reconstructor can use
3802 /// none of it until every one has landed, so a single drop used to cost the Session on
3803 /// `max_ssa_delivery_time` — after the Entry had already been told to fund the cycle. This is the
3804 /// repair: the reconstructor says which polynomial runs are missing, and the Entry re-sends
3805 /// exactly those.
3806 ///
3807 /// Deliberately *not* built on [`send_ssa_request`](Self::send_ssa_request), whose every step is
3808 /// wrong here. It registers no Exit commitment — the cycle already has one, and registering a
3809 /// second for the same index is refused as a duplicate — allocates no index, and asks the deposit
3810 /// pool for nothing, since the deposit data of this cycle travelled with the request that created
3811 /// it. Nothing it does can earn the Entry a deposit, which is what makes it safe to repeat.
3812 ///
3813 /// The scope is read here rather than carried in the action: what is missing changes as the burst
3814 /// continues to arrive, and the freshest answer is both smaller and the one that converges.
3815 async fn send_commitment_retransmission_request(
3816 &self,
3817 session_id: SessionId,
3818 slot: &SessionSlot,
3819 ssa_id: SsaId<HoprPseudonym>,
3820 params: PixParams,
3821 ) -> errors::Result<()> {
3822 let pix_toolbox = self.pix_toolbox.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3823 let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
3824
3825 // Asked of the encoder, so the scope cannot be sized against a restated layout — see
3826 // `StartProtocol::max_missing_runs`.
3827 let max_runs = HoprStartProtocol::max_missing_runs(&session_id).map_err(SessionManagerError::other)?;
3828
3829 let Some(runs) = pix_toolbox.share_processor.missing_commitment_runs(&ssa_id, max_runs) else {
3830 // The registration is gone — retired, or expired after
3831 // `incomplete_commitment_lifetime` — so there is nothing left for a retransmission to
3832 // complete. The commitment deadline is what ends the Session; saying so here is the only
3833 // way an operator learns which of the two it was.
3834 warn!(%session_id, %ssa_id, "cannot re-request commitment parts for a cycle the reconstructor no longer holds");
3835 return Ok(());
3836 };
3837
3838 if runs.is_empty() {
3839 // The commitment completed between the timer firing and this running. Its
3840 // `CommitmentVerified` is already on its way to the supervisor, which clears the timer.
3841 debug!(%session_id, %ssa_id, "commitment completed before its parts could be re-requested");
3842 return Ok(());
3843 }
3844
3845 let missing_polynomials: u32 = runs.iter().map(|(first, last)| (last - first) as u32 + 1).sum();
3846 info!(
3847 %session_id, %ssa_id, runs = runs.len(), missing_polynomials,
3848 "re-requesting the SSA commitment parts that did not arrive"
3849 );
3850
3851 send_via_msg_sender(
3852 &mut msg_sender,
3853 slot.routing_opts.clone(),
3854 HoprStartProtocol::SsaRequest(SsaServerCommitmentMessage::recommit(
3855 session_id,
3856 params,
3857 [(ssa_id.ssa_index(), runs)],
3858 )),
3859 "ssa commitment retransmission request",
3860 )
3861 .await
3862 .map_err(TransportSessionError::packet_sending)?;
3863
3864 Ok(())
3865 }
3866
3867 /// Returns the current number of active sessions.
3868 pub fn num_active_sessions(&self) -> usize {
3869 self.active_sessions.load(Ordering::Relaxed)
3870 }
3871
3872 /// Returns [`SessionIds`](SessionId) of all currently active sessions.
3873 pub fn active_sessions(&self) -> Vec<SessionId> {
3874 self.sessions.run_pending_tasks();
3875 self.sessions.iter().map(|(k, _)| *k).collect()
3876 }
3877
3878 /// Explicitly closes the session with the given `id`.
3879 ///
3880 /// Removes the entry from the internal session cache, closes the data channel,
3881 /// and aborts any auxiliary tasks. Returns `true` if a session was found and
3882 /// closed, `false` otherwise.
3883 ///
3884 /// This avoids waiting for the idle timeout (`time_to_idle`) or the LRU
3885 /// capacity bound to evict the entry, which is the desired behaviour when
3886 /// the caller (e.g. REST `DELETE /session`) knows the session is finished.
3887 ///
3888 /// One case departs from that: an Exit-side PIX Session with
3889 /// [`fill.drain_after_close`](crate::PixFillConfig::drain_after_close) on and spendable SURBs
3890 /// left starts a SURB drain instead of being torn down (see `begin_surb_drain`). The data path
3891 /// still ends at once, but the session slot and the keep-alive stream stay in place until the
3892 /// supervisor closes the Session — whether because the funded cycle recovered or because the
3893 /// drain was refused — and `true` then means "found, and now draining" rather than "found and
3894 /// closed".
3895 pub fn close_session(&self, id: &SessionId) -> bool {
3896 self.close_session_with_reason(id, ClosureReason::Eviction)
3897 }
3898
3899 /// Answers an Exit-side close by draining the Session's buffered SURBs, when that is worth doing.
3900 ///
3901 /// A PIX Session can end long before the cycle it was funded for does. The deposit that cycle was
3902 /// paid is released only once its whole emission has ridden back to the Entry, and the address it
3903 /// sits at derives from both nodes' commitments — so tearing the Session down mid-cycle strands
3904 /// money both sides have already parted with. When the Exit still holds enough SURBs to finish,
3905 /// it keeps the keep-alive stream running instead, and the Session's slot with it, until the
3906 /// supervisor closes the Session for good.
3907 ///
3908 /// Returns `None` when this close was **not** answered here, which is every case the caller must
3909 /// handle exactly as it always has; `Some(v)` is the value
3910 /// [`close_session_with_reason`](Self::close_session_with_reason) should return. `Some` currently
3911 /// always carries `true` — a drain is only ever started for a Session that was found — but the
3912 /// value is returned rather than assumed so that the two outcomes stay distinguishable if a
3913 /// future refusal path wants to report one.
3914 ///
3915 /// The order of the checks is load-bearing:
3916 ///
3917 /// 1. the configuration, so that with `drain_after_close` (or `fill.enabled`) off nothing below runs and every
3918 /// close path is byte-for-byte what it was;
3919 /// 2. the slot, by `get` rather than `remove` — the lookup also refreshes the idle timer the drain lives on;
3920 /// 3. an Exit-side PIX Session, so an Entry-side or non-PIX Session falls straight through;
3921 /// 4. an already-draining Session: a `WriteClosed`/`EmptyRead` is the end-of-stream our own ingress abort produces
3922 /// and is absorbed, while anything else — a second operator close, or a peer `SessionError` — forces the hard
3923 /// teardown, which is the operator's way to overrule a drain;
3924 /// 5. a PIX failure already tearing the Session down, which nothing here should interfere with;
3925 /// 6. the SURB estimate, which is the one half of the predicate this layer can answer — see the comment on it for
3926 /// why answering it here rather than leaving it all to the supervisor matters;
3927 /// 7. and only then the stream is put into drain mode, **before** the supervisor is told, so that no packet can be
3928 /// classified as the reserve-exempt notification in the window between the supervisor deciding and the stream
3929 /// learning.
3930 ///
3931 /// What it does *not* do is decrement `active_sessions` or remove the slot: the supervisor's own
3932 /// `Close` teardown does both, whether the drain completes or is refused a channel hop later.
3933 /// `Balancer`, `KeepAlive`, `SurbNotifyDelay`, `PixActionDriver` and the deposit observers are
3934 /// all left running; only the ingress is aborted, so the session server sees its end of the
3935 /// stream as promptly as it would have.
3936 fn begin_surb_drain(&self, id: &SessionId, reason: ClosureReason) -> Option<bool> {
3937 let fill_cfg = &self.cfg.pix_config.supervision.fill;
3938 if !(fill_cfg.enabled && fill_cfg.drain_after_close) {
3939 return None;
3940 }
3941
3942 let slot = self.sessions.get(id)?;
3943 let supervisor = slot.pix_supervisor.get()?;
3944 let fill = slot.pix_fill.get()?;
3945
3946 if fill.is_draining() {
3947 return answer_while_draining(reason);
3948 }
3949
3950 if matches!(reason, ClosureReason::PixFailure | ClosureReason::MissingDepositData) {
3951 return None;
3952 }
3953
3954 // Nothing to drain *with* is decidable here, and cheaply, unlike whether there is anything
3955 // worth draining *for* — which needs the cycle state only the supervisor holds. Asking it
3956 // anyway would defer the teardown of every PIX Session by however long the action driver
3957 // takes to reach the answer, and a driver mid-`send_ssa_request` can be seconds away from
3958 // that. So a Session whose estimate is already at or below its reserve is torn down here and
3959 // now, exactly as it always was: no rate the supervisor could plan would put a packet on the
3960 // wire against a buffer that shallow.
3961 let drainable_surbs = fill.drainable_surbs();
3962 if drainable_surbs == 0 {
3963 return None;
3964 }
3965
3966 // The check above is only a fast path: `close_session_with_reason` is reached from the
3967 // operator, the closure notifier and `handle_session_error` independently, so two closes can
3968 // both read it as false. The transition is what actually decides, and the loser is answered
3969 // as it would have been had the two been serialized rather than concurrent — which is what
3970 // keeps the supervisor from being told a Session closed twice.
3971 if !fill.enter_drain() {
3972 return answer_while_draining(reason);
3973 }
3974
3975 if !supervisor.try_send_event(SessionPixEvent::SessionClosed { drainable_surbs }) {
3976 return None;
3977 }
3978
3979 // The data path ends here regardless of what the supervisor decides: a writer parked on the
3980 // egress gate is waiting for a Session that has gone, and the reader's end-of-stream is what
3981 // the session server is waiting for.
3982 if let Some(gate) = slot.pix_egress_gate.get() {
3983 gate.poison();
3984 }
3985 slot.abort_handles.lock().abort_one(&SessionHandles::Ingress);
3986
3987 #[cfg(feature = "telemetry")]
3988 set_session_state(id, SessionLifecycleState::Closing);
3989
3990 info!(
3991 session_id = %id,
3992 ?reason,
3993 drainable_surbs,
3994 "session closed at the exit — draining its buffered SURBs into the funded cycle"
3995 );
3996 Some(true)
3997 }
3998
3999 /// [`close_session`](Self::close_session) with the reason spelled out.
4000 ///
4001 /// The reason is what the operator reads when a PIX Session stops, and the failures are hard to
4002 /// tell apart from the outside, so a caller that knows which one it is says so.
4003 ///
4004 /// `true` means the Session was found and dealt with. Since
4005 /// [`begin_surb_drain`](Self::begin_surb_drain), "dealt with" also covers "found, and now
4006 /// draining its remaining SURBs into a funded cycle" — in which case the slot is deliberately
4007 /// left in place until the supervisor's own teardown removes it.
4008 fn close_session_with_reason(&self, id: &SessionId, reason: ClosureReason) -> bool {
4009 if let Some(handled) = self.begin_surb_drain(id, reason) {
4010 return handled;
4011 }
4012
4013 if let Some(slot) = self.sessions.remove(id) {
4014 self.active_sessions.fetch_sub(1, Ordering::Relaxed);
4015 // Reconstructor state is released by `close_session` aborting the PIX action driver,
4016 // whose commitment guards retire on drop.
4017 close_session(*id, slot, reason);
4018 true
4019 } else {
4020 false
4021 }
4022 }
4023
4024 /// Updates the configuration of the SURB balancer on the given [`SessionId`].
4025 ///
4026 /// Returns an error if the Session with the given `id` does not exist, or
4027 /// if it does not use SURB balancing.
4028 pub fn update_surb_balancer_config(&self, id: &SessionId, config: SurbBalancerConfig) -> errors::Result<()> {
4029 let cfg = self
4030 .sessions
4031 .get(id)
4032 .ok_or(SessionManagerError::NonExistingSession)?
4033 .surb_mgmt;
4034
4035 // Only update the config if there already was one before
4036 if !cfg.is_disabled() {
4037 cfg.update(&config);
4038 Ok(())
4039 } else {
4040 Err(SessionManagerError::other(anyhow!("session does not use SURB balancing")).into())
4041 }
4042 }
4043
4044 /// Retrieves the configuration of SURB balancing for the given Session.
4045 ///
4046 /// Returns an error if the Session with the given `id` does not exist.
4047 pub fn get_surb_balancer_config(&self, id: &SessionId) -> errors::Result<Option<SurbBalancerConfig>> {
4048 match self.sessions.get(id) {
4049 Some(session) => Ok(Some(session.surb_mgmt.as_ref())
4050 .filter(|c| !c.is_disabled())
4051 .map(|d| d.as_config())),
4052 None => Err(SessionManagerError::NonExistingSession.into()),
4053 }
4054 }
4055
4056 /// Gets estimations produced/received and consumed SURBs by the Session.
4057 ///
4058 /// For an outgoing Session (Entry) the pair is the number of SURBs sent (by us) and used (by the Exit).
4059 /// For an incoming Session (Exit) the pair is the number of SURBs received (from Entry) and used (by us).
4060 ///
4061 /// Returns an error if the Session with the given `id` does not exist.
4062 pub fn get_surb_level_estimates(&self, id: &SessionId) -> errors::Result<(u64, u64)> {
4063 match self.sessions.get(id) {
4064 Some(session) => Ok((
4065 session
4066 .surb_estimator
4067 .produced
4068 .load(std::sync::atomic::Ordering::Relaxed),
4069 session
4070 .surb_estimator
4071 .consumed
4072 .load(std::sync::atomic::Ordering::Relaxed),
4073 )),
4074 None => Err(SessionManagerError::NonExistingSession.into()),
4075 }
4076 }
4077
4078 /// Forwards a PIX protocol observation to the Session's supervisor.
4079 ///
4080 /// The manager no longer interprets these. Deciding what an early-recovery signal or a failed
4081 /// share means for a Session's lifecycle needs the whole picture — which SSAs are in flight,
4082 /// what phase each is in, how much service has been consumed — and that lives in the supervisor.
4083 ///
4084 /// A `NonExistingSession` error is expected traffic rather than a fault: acknowledgements
4085 /// outlive their Session by up to the reconstructor's ack window, so events for a just-closed
4086 /// Session are routine. Callers distinguish it for exactly that reason.
4087 pub async fn dispatch_pix_event(&self, event: HoprSessionInPixEvent) -> errors::Result<()> {
4088 // Copied rather than borrowed, so the match below can move the event's payload out.
4089 let session_id = *event.pseudonym();
4090 let Some(slot) = self.sessions.get(&session_id) else {
4091 debug!(%session_id, "pix event for a session that is no longer registered");
4092 return Err(SessionManagerError::NonExistingSession.into());
4093 };
4094
4095 let Some(supervisor) = slot.pix_supervisor.get() else {
4096 // Entry side, or a Session that negotiated no PIX: nothing supervises it, so there is
4097 // nothing an observation could act on.
4098 trace!(%session_id, "pix event on a session without a supervisor");
4099 return Ok(());
4100 };
4101
4102 // Progress is the one input whose rate is set by traffic rather than by lifecycle
4103 // transitions, so it is delivered without backpressure — awaiting channel capacity here
4104 // would put the supervisor's scheduling latency on the acknowledgement path. Safe by
4105 // construction rather than by tolerance: snapshots carry absolute counters and the state
4106 // machine keeps the maximum it has seen, so a dropped one is indistinguishable from a late
4107 // one, and the next one supersedes it.
4108 if let HoprSessionInPixEvent::RecoveryProgress(progress) = event {
4109 #[cfg(feature = "telemetry")]
4110 telemetry::set_pix_recovery_progress(&session_id, progress.useful_shares, progress.target_useful_shares);
4111
4112 if !supervisor.try_send_progress(progress) {
4113 trace!(%session_id, "dropped a pix progress snapshot on a full supervisor channel");
4114 }
4115 return Ok(());
4116 }
4117
4118 let sent = match event {
4119 HoprSessionInPixEvent::SsaAlmostRecovered(ssa_id) => {
4120 supervisor.send_event(SessionPixEvent::AlmostRecovered(ssa_id)).await
4121 }
4122 HoprSessionInPixEvent::SsaRecovered(ssa_id) => {
4123 supervisor.send_event(SessionPixEvent::Recovered(ssa_id)).await
4124 }
4125 HoprSessionInPixEvent::UnverifiableShares {
4126 ssa_id,
4127 observed_total,
4128 peer,
4129 } => {
4130 supervisor
4131 .send_event(SessionPixEvent::UnverifiableShares {
4132 ssa_id,
4133 observed_total,
4134 peer,
4135 })
4136 .await
4137 }
4138 HoprSessionInPixEvent::RecoveryProgress(_) => unreachable!("handled above"),
4139 };
4140
4141 if sent.is_err() {
4142 error!(%session_id, "pix supervisor is no longer accepting events");
4143 }
4144
4145 Ok(())
4146 }
4147
4148 /// Marks the return path to `destination` as degraded on every Session routed there.
4149 ///
4150 /// The Session layer cannot tell a dead return path from a peer with nothing to say -- both
4151 /// simply stop consuming SURBs -- so the judgement is made where sibling paths can be compared
4152 /// and delivered here. Sessions that did not opt in ignore the mark; the rest stop trusting
4153 /// their counterparty buffer estimate for `grace`.
4154 ///
4155 /// Returns the pseudonyms of the Sessions that were marked (empty when nothing routes there).
4156 ///
4157 /// The caller uses these to advance each Session's SURB generation: the return path to
4158 /// `destination` has just been re-planned, so the fresh SURB batch must supersede the SURBs the
4159 /// counterparty still holds for the dead path.
4160 pub fn mark_return_path_degraded(
4161 &self,
4162 destination: &hopr_api::types::internal::NodeId,
4163 grace: std::time::Duration,
4164 ) -> Vec<HoprPseudonym> {
4165 self.sessions
4166 .iter()
4167 .filter(|(_, slot)| {
4168 // Only sessions that actually run a return path are affected: with `return_options:
4169 // None` the session mints no return SURBs, so the counterparty holds none to
4170 // supersede and there is nothing to mark or bump.
4171 matches!(
4172 &slot.routing_opts,
4173 DestinationRouting::Forward { destination: d, return_options: Some(_), .. }
4174 if d.as_ref() == destination
4175 )
4176 })
4177 .map(|(pseudonym, slot)| {
4178 slot.surb_mgmt.mark_return_path_degraded(grace);
4179 // The session cache is keyed by pseudonym, which is exactly the SURB-generation key.
4180 *pseudonym.as_ref()
4181 })
4182 .collect()
4183 }
4184
4185 /// The main method to be called whenever data are received.
4186 ///
4187 /// It tries to recognize the message and correctly dispatches either
4188 /// the Session protocol or Start protocol messages.
4189 ///
4190 /// If the data are not recognized, they are returned as [`DispatchResult::Unrelated`].
4191 pub fn dispatch_message(
4192 &self,
4193 pseudonym: HoprPseudonym,
4194 in_data: ApplicationDataIn,
4195 ) -> errors::Result<DispatchResult> {
4196 if in_data.data.application_tag == HoprStartProtocol::START_PROTOCOL_MESSAGE_TAG {
4197 // Start-protocol traffic bypasses `session_rx`, but an Exit → Entry message still
4198 // consumed one return SURB and unlocked one PIX share. Count it on outgoing Sessions
4199 // before handing it to the protocol worker; incoming Sessions receive this traffic from
4200 // the Entry over the forward path and must not be credited.
4201 if let Some(session_slot) = self.sessions.get(&pseudonym)
4202 && matches!(&session_slot.routing_opts, DestinationRouting::Forward { .. })
4203 {
4204 session_slot
4205 .returned_packets
4206 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4207
4208 // The only place the Exit's SURB supply can be heard from a quiet Session. The
4209 // conversion below takes `in_data.data` and drops the packet info with it, so by the
4210 // time `handle_keep_alive` runs the signals are gone -- and keep-alives are all a
4211 // Session that has stopped exchanging data still sends.
4212 session_slot
4213 .surb_mgmt
4214 .observe_counterparty_signals(in_data.packet_info.signals_from_sender);
4215 }
4216
4217 // This is a Start protocol message, so we send it to the handler
4218 trace!("dispatching Start protocol message");
4219 if let Some(start_protocol_tx) = self.start_protocol_tx.get() {
4220 start_protocol_tx
4221 .try_send((pseudonym, HoprStartProtocol::try_from(in_data.data)?))
4222 .map_err(|error| {
4223 error!(%error, "failed to send Start protocol message to processing task");
4224 SessionManagerError::other(error)
4225 })?;
4226 } else {
4227 return Err(SessionManagerError::NotStarted.into());
4228 }
4229
4230 #[cfg(all(feature = "telemetry", not(test)))]
4231 METRIC_DISPATCHED_MSGS.increment_by(&["processed"], 1);
4232
4233 return Ok(DispatchResult::Processed);
4234 } else if in_data.data.application_tag == SESSION_APPLICATION_TAG {
4235 // This is traffic that belongs to one of the Sessions
4236 let session_id = pseudonym;
4237
4238 // How often a sustained inbox-full condition is allowed to log. Backpressure can
4239 // affect every packet on the hot path, so warning per drop would reproduce exactly
4240 // the ERROR spam this dispatch was rewritten to avoid — warn once per interval instead.
4241 const SESSION_INBOX_FULL_WARN_INTERVAL: usize = 256;
4242
4243 return if let Some(session_slot) = self.sessions.get(&session_id) {
4244 trace!(%session_id, "received data for a registered session");
4245
4246 // Recorded here rather than once the Session reads the packet, because the send
4247 // below can drop it for local backpressure and the reader would never see it. What
4248 // the counterparty says about its own SURB supply is the override on the organic
4249 // SURB gate, so losing it to a full inbox would leave production shut off on an
4250 // estimate the counterparty has just contradicted.
4251 if matches!(&session_slot.routing_opts, DestinationRouting::Forward { .. }) {
4252 session_slot
4253 .surb_mgmt
4254 .observe_counterparty_signals(in_data.packet_info.signals_from_sender);
4255 }
4256
4257 match session_slot.session_tx.try_send(in_data) {
4258 Ok(_) => {
4259 #[cfg(all(feature = "telemetry", not(test)))]
4260 METRIC_DISPATCHED_MSGS.increment_by(&["processed"], 1);
4261
4262 Ok(DispatchResult::Processed)
4263 }
4264 // Benign teardown race: the slot is still registered but its data sink was
4265 // dropped. Quiet on purpose — see [`DropReason`].
4266 Err(crossfire::TrySendError::Disconnected(_)) => {
4267 trace!(%session_id, "dropping data for a session whose sink has closed");
4268 crate::counters::SESSION_INBOX_CLOSED_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4269 #[cfg(all(feature = "telemetry", not(test)))]
4270 METRIC_DISPATCHED_MSGS.increment_by(&["dropped_sink_closed"], 1);
4271 Ok(DispatchResult::Dropped(DropReason::SinkClosed))
4272 }
4273 // Genuine backpressure: the reader is not keeping up. Rate-limited so a
4274 // sustained overload cannot become per-packet log spam.
4275 Err(crossfire::TrySendError::Full(_)) => {
4276 let prev =
4277 crate::counters::SESSION_INBOX_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4278 if prev.is_multiple_of(SESSION_INBOX_FULL_WARN_INTERVAL) {
4279 warn!(%session_id, total_inbox_full_drops = prev + 1, "session inbox full, dropping data (backpressure)");
4280 }
4281 #[cfg(all(feature = "telemetry", not(test)))]
4282 METRIC_DISPATCHED_MSGS.increment_by(&["dropped_sink_full"], 1);
4283 Ok(DispatchResult::Dropped(DropReason::SinkFull))
4284 }
4285 }
4286 } else {
4287 // Fully deregistered session: packets still in flight for a torn-down session.
4288 // Quiet — see [`DropReason::Unregistered`].
4289 trace!(%session_id, "dropping data for an unregistered session");
4290 crate::counters::SESSION_UNKNOWN_DATA_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4291 #[cfg(all(feature = "telemetry", not(test)))]
4292 METRIC_DISPATCHED_MSGS.increment_by(&["dropped_unregistered"], 1);
4293 Ok(DispatchResult::Dropped(DropReason::Unregistered))
4294 };
4295 }
4296
4297 trace!(tag = %in_data.data.application_tag, "received data not associated with session protocol or any existing session");
4298
4299 crate::counters::SESSION_UNRELATED_DATA_DISPATCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4300
4301 #[cfg(all(feature = "telemetry", not(test)))]
4302 METRIC_DISPATCHED_MSGS.increment_by(&["unrelated"], 1);
4303
4304 Ok(DispatchResult::Unrelated(in_data))
4305 }
4306
4307 /// Pre-populates the sessions cache with a session slot for benchmarking.
4308 ///
4309 /// Intended for benchmarks that need a session to exist before calling
4310 /// [`SessionManager::dispatch_message`].
4311 ///
4312 /// Requires the `"benchmark"` feature (or a test build).
4313 #[cfg(any(feature = "benchmark", test))]
4314 pub fn pre_populate_session(&self, session_id: SessionId, routing_opts: DestinationRouting) {
4315 let (session_tx, _) =
4316 crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
4317 let slot = SessionSlot {
4318 session_tx,
4319 routing_opts,
4320 abort_handles: Default::default(),
4321 surb_mgmt: Arc::new(BalancerStateValues::default()),
4322 surb_estimator: Default::default(),
4323 current_ssa_state: Default::default(),
4324 pix_supervisor: Default::default(),
4325 pix_egress_gate: Default::default(),
4326 pix_fill: Default::default(),
4327 returned_packets: Default::default(),
4328 // Never PIX, so there is no reconstructor state to charge for.
4329 cycle_budget: None,
4330 };
4331 self.sessions.insert(session_id, slot);
4332 self.slot_allocated
4333 .lock()
4334 .unwrap_or_else(|e| e.into_inner())
4335 .remove(&session_id);
4336 }
4337
4338 /// Like [`pre_populate_session`](SessionManager::pre_populate_session) but also returns the
4339 /// session channel receiver so the caller can spawn a drain task.
4340 ///
4341 /// Requires the `"benchmark"` feature (or a test build).
4342 #[cfg(any(feature = "benchmark", test))]
4343 pub fn pre_populate_session_with_receiver(
4344 &self,
4345 session_id: SessionId,
4346 routing_opts: DestinationRouting,
4347 ) -> crossfire::AsyncRx<crossfire::mpsc::Array<ApplicationDataIn>> {
4348 let (session_tx, session_rx) =
4349 crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
4350 let slot = SessionSlot {
4351 session_tx,
4352 routing_opts,
4353 abort_handles: Default::default(),
4354 surb_mgmt: Arc::new(BalancerStateValues::default()),
4355 surb_estimator: Default::default(),
4356 current_ssa_state: Default::default(),
4357 pix_supervisor: Default::default(),
4358 pix_egress_gate: Default::default(),
4359 pix_fill: Default::default(),
4360 returned_packets: Default::default(),
4361 // Never PIX, so there is no reconstructor state to charge for.
4362 cycle_budget: None,
4363 };
4364 self.sessions.insert(session_id, slot);
4365 self.slot_allocated
4366 .lock()
4367 .unwrap_or_else(|e| e.into_inner())
4368 .remove(&session_id);
4369 session_rx
4370 }
4371
4372 /// Charges `params`' worth of reconstructor state against the node-wide budget.
4373 ///
4374 /// Returns `None` if the node has already committed to as much as
4375 /// [`IncomingSessionPixConfig::max_live_cycle_bytes`] allows — the caller must then refuse the
4376 /// Session, because nothing later in establishment can give the memory back.
4377 ///
4378 /// A CAS loop rather than an unconditional `fetch_add` with a rollback: an add that is
4379 /// provisionally over the ceiling is briefly visible to every concurrent initiation, and with
4380 /// enough of them arriving at once the budget would appear exhausted to Sessions that do fit.
4381 fn reserve_cycle_budget(&self, params: &PixParams, ssas_per_request: usize) -> Option<Arc<CycleBudgetReservation>> {
4382 let bytes = cycle_budget_for(params, ssas_per_request);
4383 let ceiling = self.cfg.pix_config.max_live_cycle_bytes;
4384
4385 self.live_cycle_bytes
4386 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |held| {
4387 held.checked_add(bytes).filter(|total| *total <= ceiling)
4388 })
4389 .ok()
4390 .map(|_| {
4391 // Reports only this reservation's own bytes, not an outstanding total. Initiations
4392 // are processed concurrently, so any total computed here can be published out of
4393 // order with another reservation's; the gauge adds instead, which commutes.
4394 #[cfg(feature = "telemetry")]
4395 crate::telemetry::pix::record_cycle_bytes_reserved(bytes);
4396
4397 Arc::new(CycleBudgetReservation {
4398 bytes,
4399 outstanding: self.live_cycle_bytes.clone(),
4400 released: std::sync::atomic::AtomicBool::new(false),
4401 })
4402 })
4403 }
4404
4405 /// Asks the session server on what terms this Session may be admitted.
4406 ///
4407 /// Fails closed. A refusal, a server that does not answer in time, a full request channel and a
4408 /// dropped reply all end the Session here rather than falling back to the node's own terms:
4409 /// falling back would mean an overloaded or wedged server silently disables every rule the
4410 /// operator wrote, and the peer would be served on terms nobody chose.
4411 ///
4412 /// The two failure kinds are reported differently because they call for different responses. A
4413 /// server that answered `Err` has decided, and re-asking will not change the answer, so the peer
4414 /// is told [`StartErrorReason::TargetNotAdmitted`]. Everything else is this node being unable to
4415 /// ask right now, which is transient, so the peer is told [`StartErrorReason::Busy`] and may
4416 /// retry.
4417 ///
4418 /// A node with no session server has nobody to ask, and admits on its own terms.
4419 async fn request_admission(
4420 &self,
4421 session_id: SessionId,
4422 req: &StartInitiation<SessionTarget, HoprSessionCapabilities>,
4423 offered: Option<PixParams>,
4424 ) -> Result<SessionAdmissionDecision, StartErrorReason> {
4425 let Some(admission_tx) = self.admission_tx.get() else {
4426 return Ok(SessionAdmissionDecision::default());
4427 };
4428
4429 let (reply_tx, reply_rx) = oneshot::channel();
4430 // The capability bits travel as the peer sent them; the offer travels decoded, since the
4431 // wire encoding is this crate's to read and the server has no business unpacking it.
4432 let request = SessionAdmissionRequest::new(session_id, req.target.clone(), req.capabilities.0.bits());
4433 let request = match offered {
4434 Some(params) => request.with_offer(OfferedIncentivization::new(
4435 params.polys_per_ssa(),
4436 params.shares_per_poly(),
4437 params.surplus_shares(),
4438 pix_params_to_quota(¶ms),
4439 )),
4440 None => request,
4441 };
4442
4443 // `try_send` rather than an awaited send: waiting for room in the queue would hold this
4444 // initiation past the peer's own timeout, and the queue being full is exactly the condition
4445 // the bound exists to report.
4446 if let Err(error) = admission_tx.lock().try_send((request, reply_tx)) {
4447 warn!(
4448 %session_id,
4449 %error,
4450 "cannot ask the session server to admit this session; refusing it as busy"
4451 );
4452 return Err(StartErrorReason::Busy);
4453 }
4454
4455 match reply_rx
4456 .timeout(futures_time::time::Duration::from(self.cfg.session_admission_timeout))
4457 .await
4458 {
4459 Ok(Ok(Ok(decision))) => Ok(decision),
4460 Ok(Ok(Err(error))) => {
4461 debug!(%session_id, %error, "the session server refused this session's target");
4462 Err(StartErrorReason::TargetNotAdmitted)
4463 }
4464 // The server dropped the reply without answering, which is a bug in it rather than a
4465 // decision, so it is reported as transient like the timeout it resembles.
4466 Ok(Err(_)) => {
4467 error!(
4468 %session_id,
4469 "the session server dropped an admission request without answering it"
4470 );
4471 Err(StartErrorReason::Busy)
4472 }
4473 Err(_) => {
4474 warn!(
4475 %session_id,
4476 timeout = ?self.cfg.session_admission_timeout,
4477 "the session server did not decide in time; refusing the session as busy"
4478 );
4479 Err(StartErrorReason::Busy)
4480 }
4481 }
4482 }
4483
4484 /// Checks the offer against the terms this Session was admitted on.
4485 ///
4486 /// Judged against `policy` — the node's configuration as narrowed by the session server's
4487 /// decision for this Session — rather than against the node's configuration directly, which is
4488 /// what makes the accepted quota a property of the target as well as of the node.
4489 ///
4490 /// `offered` is what [`decode_pix_offer`] read, so this is only the
4491 /// policy question; the wire has already been judged.
4492 ///
4493 /// Returns the validated parameters and selected SSA batch size, or `None` if the offer cannot
4494 /// satisfy that policy.
4495 fn check_pix_params(
4496 &self,
4497 challenge: StartChallenge,
4498 offered: Option<PixParams>,
4499 policy: &EffectivePixAdmission,
4500 ) -> Option<(PixParams, usize)> {
4501 if let Some(params) = offered {
4502 let quota_per_ssa = pix_params_to_quota(¶ms);
4503 let ssas_per_request = policy.ssa_batch_size_for_quota(quota_per_ssa);
4504 let accepted_quota = ssas_per_request.and_then(|batch_size| quota_per_ssa.checked_mul(batch_size as u64));
4505 debug!(
4506 challenge,
4507 %params,
4508 acceptable_range = ?policy.quota_range,
4509 node_range = ?self.cfg.pix_config.quota_range,
4510 dynamic_batches = policy.allow_dynamic_ssa_batches,
4511 configured_max_ssas_per_request = policy.ssas_per_request,
4512 selected_ssas_per_request = ?ssas_per_request,
4513 offered_quota_mb_per_ssa = quota_per_ssa as f64 / (1024.0 * 1024.0),
4514 accepted_quota_mb = ?accepted_quota.map(|quota| quota as f64 / (1024.0 * 1024.0)),
4515 "client offered PIX SSA quota"
4516 );
4517
4518 // A window narrowed to nothing means the session server's rule and this node's envelope
4519 // do not overlap, which is a misconfiguration rather than a peer offering badly: no
4520 // offer whatsoever could have been accepted, so it is worth saying out loud once per
4521 // Session rather than leaving it to look like a rejected client.
4522 if ssas_per_request.is_none() && policy.quota_range.is_empty() {
4523 warn!(
4524 challenge,
4525 admitted_range = ?policy.quota_range,
4526 node_range = ?self.cfg.pix_config.quota_range,
4527 "the admitted quota range for this target is empty, so no offer can be accepted"
4528 );
4529 }
4530
4531 ssas_per_request.map(|batch_size| (params, batch_size))
4532 } else if policy.enforce_pix {
4533 // Client didn't offer PIX, but PIX is enforced
4534 None
4535 } else {
4536 // Client didn't offer PIX, and PIX is not enforced, so set default values
4537 // which are not going to be used.
4538 Some((DEFAULT_PIX_PARAMS, 1))
4539 }
4540 }
4541
4542 #[tracing::instrument(level = "debug", skip(self, session_req))]
4543 async fn handle_incoming_session_initiation(
4544 &self,
4545 pseudonym: HoprPseudonym,
4546 session_req: StartInitiation<SessionTarget, HoprSessionCapabilities>,
4547 ) -> errors::Result<()> {
4548 trace!(challenge = session_req.challenge, "received session initiation request");
4549
4550 debug!("got new session request, searching for a free session slot");
4551
4552 let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
4553
4554 // Reply routing uses SURBs only with the pseudonym of this Session's ID
4555 let reply_routing = DestinationRouting::Return(pseudonym.into());
4556
4557 // Reject UsePIX if this node is not configured with a PixToolbox
4558 // (e.g. relay nodes that do not participate in PIX processing).
4559 if self.pix_toolbox.get().is_none() && session_req.capabilities.0.contains(Capability::UsePIX) {
4560 error!(
4561 challenge = session_req.challenge,
4562 "client offered PIX but this node has no PIX support installed"
4563 );
4564 let data = HoprStartProtocol::SessionError(StartErrorType {
4565 identifier: ErrorIdentifier::Challenge(session_req.challenge),
4566 reason: StartErrorReason::UnacceptablePixParams,
4567 });
4568 // Counted before the send, not after: the refusal has already happened, and
4569 // `send_via_msg_sender` can fail to encode, fail to send, or time out. Counting
4570 // afterwards would drop exactly the refusals that a struggling node makes most.
4571 PixAdmissionRejection::NoPixSupport.record_if_pix(session_req.capabilities.0);
4572 send_via_msg_sender(
4573 &mut msg_sender,
4574 reply_routing,
4575 data,
4576 "session error due to missing PIX support",
4577 )
4578 .await?;
4579 return Ok(());
4580 }
4581
4582 // Ask whoever runs the session server on what terms this target may be served, before
4583 // anything is allocated for the Session. This is the only place the target can influence
4584 // admission: the transport cannot read a sealed target, and by the time the server sees the
4585 // established Session the terms have already been fixed.
4586 // Read the offer before anyone is asked about it: an offer this node cannot read, or whose
4587 // curve it does not share, is refused without a round trip to the session server, and the
4588 // server is told what was offered rather than having to unpack the wire itself.
4589 let offered = match decode_pix_offer(&session_req) {
4590 Ok(offered) => offered,
4591 Err(reason) => {
4592 error!(
4593 challenge = session_req.challenge,
4594 "client offered unacceptable PIX parameters"
4595 );
4596 let data = HoprStartProtocol::SessionError(StartErrorType {
4597 identifier: ErrorIdentifier::Challenge(session_req.challenge),
4598 reason,
4599 });
4600 PixAdmissionRejection::UnacceptableParams.record_if_pix(session_req.capabilities.0);
4601 send_via_msg_sender(
4602 &mut msg_sender,
4603 reply_routing,
4604 data,
4605 "session error message due to unacceptable PIX parameters",
4606 )
4607 .await?;
4608
4609 // Left after the send, unlike the line above: this one counts errors *sent*, so a
4610 // send that never happened is correctly not one of them.
4611 #[cfg(all(feature = "telemetry", not(test)))]
4612 METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4613 return Ok(());
4614 }
4615 };
4616
4617 let session_id: SessionId = pseudonym;
4618 let admission = match self.request_admission(session_id, &session_req, offered).await {
4619 Ok(decision) => decision,
4620 Err(reason) => {
4621 let data = HoprStartProtocol::SessionError(StartErrorType {
4622 identifier: ErrorIdentifier::Challenge(session_req.challenge),
4623 reason,
4624 });
4625 // `request_admission` distinguishes these two on purpose — a refused target will not
4626 // be served however the request is phrased, while `Busy` is this node being unable
4627 // to ask right now and is worth retrying — so the aggregate keeps them apart too.
4628 match reason {
4629 StartErrorReason::TargetNotAdmitted => PixAdmissionRejection::TargetPolicy,
4630 _ => PixAdmissionRejection::Busy,
4631 }
4632 .record_if_pix(session_req.capabilities.0);
4633 send_via_msg_sender(
4634 &mut msg_sender,
4635 reply_routing,
4636 data,
4637 "session error due to a refused admission",
4638 )
4639 .await?;
4640
4641 #[cfg(all(feature = "telemetry", not(test)))]
4642 METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4643 return Ok(());
4644 }
4645 };
4646 let pix_policy = self.cfg.pix_config.effective_for(&admission);
4647
4648 // Verify the offer against the terms this Session was admitted on.
4649 let Some((client_params, ssas_per_request)) =
4650 self.check_pix_params(session_req.challenge, offered, &pix_policy)
4651 else {
4652 error!(
4653 challenge = session_req.challenge,
4654 "client offered unacceptable PIX parameters"
4655 );
4656
4657 // Notify the sender that the session could not be established
4658 let reason = StartErrorReason::UnacceptablePixParams;
4659 let data = HoprStartProtocol::SessionError(StartErrorType {
4660 identifier: ErrorIdentifier::Challenge(session_req.challenge),
4661 reason,
4662 });
4663 PixAdmissionRejection::UnacceptableParams.record_if_pix(session_req.capabilities.0);
4664 send_via_msg_sender(
4665 &mut msg_sender,
4666 reply_routing,
4667 data,
4668 "session error message due to unacceptable PIX parameters",
4669 )
4670 .await?;
4671
4672 #[cfg(all(feature = "telemetry", not(test)))]
4673 METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4674 return Ok(());
4675 };
4676
4677 info!(params = %client_params, ssas_per_request, "client offered acceptable PIX parameters");
4678
4679 // Charge this Session's reconstructor state against the node-wide budget before anything is
4680 // allocated for it. Only a PIX Session holds cycle state, so only a PIX Session is charged —
4681 // `check_pix_params` hands back nominal parameters for a peer that offered none, and
4682 // reserving on those would bill Sessions that never reconstruct anything.
4683 //
4684 // Refused here rather than at the successor request, which is the other place the ceiling
4685 // could be applied: by then the Entry has funded a cycle and refusing costs it that deposit,
4686 // whereas a Session refused now is one the peer can retry elsewhere at no charge.
4687 let cycle_budget = if session_req.capabilities.0.contains(Capability::UsePIX) {
4688 let Some(reservation) = self.reserve_cycle_budget(&client_params, ssas_per_request) else {
4689 warn!(
4690 challenge = session_req.challenge,
4691 requested = cycle_budget_for(&client_params, ssas_per_request),
4692 outstanding = self.live_cycle_bytes.load(Ordering::Relaxed),
4693 ceiling = self.cfg.pix_config.max_live_cycle_bytes,
4694 "refusing a PIX session: the node's live reconstructor-cycle budget is exhausted"
4695 );
4696
4697 let reason = StartErrorReason::NoSlotsAvailable;
4698 let data = HoprStartProtocol::SessionError(StartErrorType {
4699 identifier: ErrorIdentifier::Challenge(session_req.challenge),
4700 reason,
4701 });
4702 // The refusal this whole aggregate exists to separate from the one below. Both are
4703 // `NoSlotsAvailable` on the wire, but this one says the node is healthy and full of
4704 // reconstructor state, and the other says it is at its Session limit.
4705 PixAdmissionRejection::LiveCycleCapacity.record_if_pix(session_req.capabilities.0);
4706 send_via_msg_sender(
4707 &mut msg_sender,
4708 reply_routing,
4709 data,
4710 "session error message due to an exhausted live-cycle budget",
4711 )
4712 .await?;
4713
4714 #[cfg(all(feature = "telemetry", not(test)))]
4715 METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4716 return Ok(());
4717 };
4718 Some(reservation)
4719 } else {
4720 None
4721 };
4722
4723 let (new_session_notifier, close_session_notifier) = self
4724 .session_notifiers
4725 .get()
4726 .cloned()
4727 .ok_or(SessionManagerError::NotStarted)?;
4728
4729 // Use constant application tag for all sessions
4730 self.sessions.run_pending_tasks();
4731
4732 let (session_tx, session_rx) =
4733 crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(self.cfg.session_forward_capacity);
4734 let (session_rx, session_rx_ah) = hopr_utils::runtime::DropAbortable::new(session_rx.into_stream());
4735
4736 let slot = SessionSlot {
4737 session_tx,
4738 routing_opts: reply_routing.clone(),
4739 abort_handles: Default::default(),
4740 surb_mgmt: Default::default(),
4741 surb_estimator: Default::default(),
4742 current_ssa_state: Default::default(),
4743 pix_supervisor: Default::default(),
4744 pix_egress_gate: Default::default(),
4745 pix_fill: Default::default(),
4746 returned_packets: Default::default(),
4747 cycle_budget,
4748 };
4749 slot.abort_handles.lock().insert(SessionHandles::Ingress, session_rx_ah);
4750
4751 // Insert the slot and get a guard. Any failure from here on rolls the slot
4752 // back, otherwise it would block this pseudonym until idle eviction. The atomic
4753 // insert (inside the helper) also prevents a TOCTOU race, so only one concurrent
4754 // request can claim the slot for a given pseudonym.
4755 let Some(mut slot_guard) = self.allocate_session_slot(session_id, slot.clone()) else {
4756 // No slots available for this pseudonym
4757 error!("no slots available for this pseudonym");
4758 let reason = StartErrorReason::NoSlotsAvailable;
4759 let data = HoprStartProtocol::SessionError(StartErrorType {
4760 identifier: ErrorIdentifier::Challenge(session_req.challenge),
4761 reason,
4762 });
4763
4764 PixAdmissionRejection::NoSessionSlot.record_if_pix(session_req.capabilities.0);
4765 send_via_msg_sender(
4766 &mut msg_sender,
4767 reply_routing.clone(),
4768 data,
4769 "session error message due to lack of slots",
4770 )
4771 .await?;
4772
4773 #[cfg(all(feature = "telemetry", not(test)))]
4774 METRIC_SENT_SESSION_ERRS.increment(&[&reason.to_string()]);
4775
4776 return Ok(());
4777 };
4778
4779 debug!(?session_req, "assigned a new session");
4780
4781 // Stand the supervisor up before the `HoprSession` is constructed, so the egress adapters
4782 // below see a populated gate. Installing it afterwards would leave a window in which packets
4783 // are served ungated — small, but exactly the window an unfunded Entry would aim for.
4784 //
4785 // Its first `RequestSsa` action is deliberately *not* carried out here: the action driver
4786 // spawned after publication does it, which is what keeps `SessionEstablished` ahead of
4787 // `SsaRequest` on the wire.
4788 let pix: Option<ActionRx> = if self.pix_toolbox.get().is_some()
4789 && session_req.capabilities.0.contains(Capability::UsePIX)
4790 {
4791 // We use the same dimensions the client offered.
4792 slot.current_ssa_state
4793 .set(SessionSsaState::new(client_params))
4794 .map_err(|_| SessionManagerError::other(anyhow::anyhow!("session pix state must be uninitialized")))?;
4795
4796 let supervisor_config = SupervisorConfig {
4797 ssas_per_request,
4798 ..self.cfg.pix_config.supervisor_config()
4799 };
4800 let (handle, action_rx) =
4801 spawn_supervisor_worker(supervisor_config, client_params, session_id, std::time::Instant::now());
4802
4803 let _ = slot.pix_egress_gate.set(handle.gate.clone());
4804 let _ = slot.pix_supervisor.set(handle);
4805 Some(action_rx)
4806 } else {
4807 None
4808 };
4809
4810 // The SURB buffer target the Entry asked for, resolved once for both branches below.
4811 //
4812 // The Session request carries a "hint" as additional data telling what the Session initiator
4813 // has configured as its target buffer size in the Balancer. The lower 32 bits of the options
4814 // word contain the SURB target; its upper 32 bits carry PIX parameters and must be masked out.
4815 //
4816 // Resolved even on the `NoRateControl` branch, which runs no balancer and never announces a
4817 // target of its own: the Exit's keep-alive stream derives its fill SURB reserve from this
4818 // figure, and on that branch the fallback below — the same one the balancer would have used —
4819 // is the only estimate of the buffer there is.
4820 let announced_surb_target = {
4821 let surb_target = initiation_options(session_req.additional_data) as u32;
4822 if surb_target > 0 {
4823 (surb_target as u64).min(self.cfg.maximum_surb_buffer_size as u64)
4824 } else {
4825 self.cfg.initial_return_session_egress_rate as u64
4826 * self
4827 .cfg
4828 .minimum_surb_buffer_duration
4829 .max(MIN_SURB_BUFFER_DURATION)
4830 .as_secs()
4831 }
4832 };
4833
4834 let closure_notifier = Box::new(move |session_id: SessionId, reason: ClosureReason| {
4835 if let Err(error) = close_session_notifier.try_send((session_id, reason)) {
4836 error!(%session_id, %error, %reason, "failed to notify session closure");
4837 }
4838 });
4839
4840 let session = if !session_req.capabilities.0.contains(Capability::NoRateControl) {
4841 // Because of SURB scarcity, control the egress rate of incoming sessions
4842 let egress_rate_control =
4843 RateController::new(self.cfg.initial_return_session_egress_rate, Duration::from_secs(1));
4844
4845 let target_surb_buffer_size = announced_surb_target;
4846
4847 let surb_estimator_tx = slot.surb_estimator.clone();
4848 let surb_estimator_rx = slot.surb_estimator.clone();
4849 // Resolved once, here, rather than per packet: the gate was installed before this point,
4850 // so the egress path never has to look inside the `OnceLock` again.
4851 let egress_gate = slot.pix_egress_gate.get().cloned();
4852 let session = HoprSession::new(
4853 session_id,
4854 reply_routing.clone(),
4855 session_config(&self.cfg, session_req.capabilities.into()),
4856 (
4857 // Sent packets = SURB consumption estimate
4858 msg_sender
4859 .clone()
4860 .sink_map_err(std::io::Error::other)
4861 .with(move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
4862 // Each outgoing packet consumes one SURB
4863 surb_estimator_tx
4864 .consumed
4865 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4866 #[cfg(feature = "telemetry")]
4867 telemetry::record_session_surb_consumed(&session_id, 1);
4868 acquire_egress_permit(egress_gate.clone(), routing, data)
4869 })
4870 .rate_limit_with_controller(&egress_rate_control)
4871 .buffer((2 * target_surb_buffer_size) as usize),
4872 // Received packets = SURB retrieval estimate
4873 session_rx.inspect(move |data| {
4874 // SURBs saved into the store, and older ones the full store evicted to make room.
4875 let produced = data.num_surbs_with_msg() as u64;
4876 let evicted = data.packet_info.num_evicted_surbs as u64;
4877 account_incoming_surbs(&surb_estimator_rx, produced, evicted);
4878 #[cfg(feature = "telemetry")]
4879 telemetry::record_session_surb_produced(&session_id, produced);
4880 }),
4881 ),
4882 Some(closure_notifier),
4883 )?;
4884
4885 // The SURB balancer will start intervening by rate-limiting the
4886 // egress of the Session, once the estimated number of SURBs drops below
4887 // the target defined here. Otherwise, the maximum egress is allowed.
4888 let balancer_config = SurbBalancerConfig {
4889 target_surb_buffer_size,
4890 // At maximum egress, the SURB buffer drains in `minimum_surb_buffer_duration` seconds
4891 max_surbs_per_sec: target_surb_buffer_size / self.cfg.minimum_surb_buffer_duration.as_secs(),
4892 // No SURB decay at the Exit, since we know almost exactly how many SURBs
4893 // were received
4894 surb_decay: None,
4895 sustain_on_return_path_loss: false,
4896 };
4897
4898 slot.surb_mgmt.update(&balancer_config);
4899 slot.surb_mgmt
4900 .set_counterparty_buffer_capacity(self.cfg.maximum_surb_buffer_size as u64);
4901
4902 // Spawn the SURB balancer only once we know we have registered the
4903 // abort handle with the pre-allocated Session slot
4904 debug!(%session_id, ?balancer_config ,"spawning exit SURB balancer");
4905 let balancer = SurbBalancer::new(
4906 session_id,
4907 SimpleBalancerController::default(),
4908 slot.surb_estimator.clone(),
4909 SurbControllerWithCorrection(egress_rate_control, 1), // 1 SURB per egress packet
4910 slot.surb_mgmt.clone(),
4911 );
4912
4913 // Assign the SURB balancer and abort handles to the already allocated Session slot
4914 let (_, balancer_abort_handle) = balancer.start_control_loop(self.cfg.balancer_sampling_interval);
4915 slot.abort_handles
4916 .lock()
4917 .insert(SessionHandles::Balancer, balancer_abort_handle);
4918
4919 // Reports the SURB buffer level towards the Entry, and — on a PIX Session — carries the
4920 // Exit's own fill.
4921 self.spawn_exit_keep_alive(
4922 session_id,
4923 &slot,
4924 msg_sender.clone(),
4925 pix.is_some(),
4926 announced_surb_target,
4927 );
4928
4929 session
4930 } else {
4931 // `NoRateControl`: no SURB balancer, but the PIX gate still applies. A Session that
4932 // opts out of rate control is exactly the one that could drain the most service before
4933 // funding, so leaving this path ungated would make the predeposit budget optional.
4934 let egress_gate = slot.pix_egress_gate.get().cloned();
4935 let surb_estimator_tx = slot.surb_estimator.clone();
4936 let surb_estimator_rx = slot.surb_estimator.clone();
4937 let session = HoprSession::new(
4938 session_id,
4939 reply_routing.clone(),
4940 session_config(&self.cfg, session_req.capabilities.into()),
4941 (
4942 msg_sender.clone().sink_map_err(std::io::Error::other).with(
4943 move |(routing, data): (DestinationRouting, ApplicationDataOut)| {
4944 // Counted here too, though nothing on this branch balances anything. The
4945 // estimate is what the fill reserve is measured against, and a reserve
4946 // measured against a counter that only ever counts in one direction is a
4947 // reserve that either never binds or never releases. `NoRateControl` is
4948 // also the branch that can drain SURBs fastest, so it is the one where
4949 // that matters most.
4950 surb_estimator_tx
4951 .consumed
4952 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4953 #[cfg(feature = "telemetry")]
4954 telemetry::record_session_surb_consumed(&session_id, 1);
4955 acquire_egress_permit(egress_gate.clone(), routing, data)
4956 },
4957 ),
4958 session_rx.inspect(move |data| {
4959 // SURBs saved into the store, and older ones the full store evicted to make room.
4960 let produced = data.num_surbs_with_msg() as u64;
4961 let evicted = data.packet_info.num_evicted_surbs as u64;
4962 account_incoming_surbs(&surb_estimator_rx, produced, evicted);
4963 #[cfg(feature = "telemetry")]
4964 telemetry::record_session_surb_produced(&session_id, produced);
4965 }),
4966 ),
4967 Some(closure_notifier),
4968 )?;
4969
4970 self.spawn_exit_keep_alive(
4971 session_id,
4972 &slot,
4973 msg_sender.clone(),
4974 pix.is_some(),
4975 announced_surb_target,
4976 );
4977
4978 session
4979 };
4980
4981 // Extract useful information about the session from the Start protocol message
4982 let incoming_session = IncomingSession {
4983 id: session_id,
4984 session,
4985 target: session_req.target,
4986 };
4987
4988 // Notify that a new incoming session has been created. Lock the sink and send
4989 // directly into it, so no extra forwarding task between channels is needed.
4990 match async {
4991 let mut guard = new_session_notifier.lock().await;
4992 guard.send(incoming_session).await
4993 }
4994 .timeout(futures_time::time::Duration::from(EXTERNAL_SEND_TIMEOUT))
4995 .await
4996 {
4997 Err(_) => {
4998 error!(%session_id, "timeout to notify about new incoming session");
4999 return Err(TransportSessionError::Timeout);
5000 }
5001 Ok(Err(error)) => {
5002 error!(%session_id, %error, "failed to notify about new incoming session");
5003 return Err(SessionManagerError::other(error).into());
5004 }
5005 _ => {}
5006 };
5007
5008 trace!(?session_id, "session notification sent");
5009
5010 // Notify the sender that the session has been established.
5011 // Set our peer ID in the session ID sent back to them.
5012 let data = HoprStartProtocol::SessionEstablished(StartEstablished {
5013 orig_challenge: session_req.challenge,
5014 session_id,
5015 });
5016
5017 send_via_msg_sender(
5018 &mut msg_sender,
5019 reply_routing.clone(),
5020 data,
5021 "session establishment message",
5022 )
5023 .await?;
5024
5025 #[cfg(feature = "telemetry")]
5026 initialize_session_telemetry(
5027 session_id,
5028 &self.cfg,
5029 session_req.capabilities.0,
5030 Some(&slot.surb_estimator),
5031 Some(&slot.surb_mgmt),
5032 );
5033
5034 // The session is published, so the supervisor's actions can now be carried out. The first of
5035 // them is the initial `RequestSsa`, which is why this runs after `SessionEstablished` has
5036 // gone out: the Entry must see the two in that order.
5037 if let Some(action_rx) = pix {
5038 let driver = self.spawn_pix_action_driver(session_id, &slot, action_rx, reply_routing.clone());
5039 slot.abort_handles
5040 .lock()
5041 .insert(SessionHandles::PixActionDriver, driver);
5042 }
5043
5044 #[cfg(all(feature = "telemetry", not(test)))]
5045 METRIC_NUM_ESTABLISHED_SESSIONS.increment();
5046
5047 slot_guard.commit();
5048
5049 Ok(())
5050 }
5051
5052 #[tracing::instrument(level = "debug", skip(self, est))]
5053 async fn handle_session_established(&self, est: StartEstablished<SessionId>) -> errors::Result<()> {
5054 debug!(
5055 session_id = ?est.session_id,
5056 "received session establishment confirmation"
5057 );
5058 let challenge = est.orig_challenge;
5059 let session_id = est.session_id;
5060
5061 if let Some(tx_est) = self.session_initiations.remove(&challenge) {
5062 if let Err(error) = tx_est.try_send(Ok(est)) {
5063 error!(%challenge, %session_id, %error, "failed to send session establishment confirmation");
5064 return Err(SessionManagerError::other(error).into());
5065 }
5066 debug!(?session_id, challenge, "session establishment complete");
5067 } else {
5068 error!(%session_id, challenge, "unknown session establishment attempt or expired");
5069 }
5070 Ok(())
5071 }
5072
5073 #[tracing::instrument(level = "debug", skip(self))]
5074 async fn handle_session_error(&self, error_type: StartErrorType<SessionId>) -> errors::Result<()> {
5075 let reason = error_type.reason;
5076 match error_type.identifier {
5077 ErrorIdentifier::Challenge(challenge) => {
5078 trace!(%challenge, error = ?error_type.reason, "session initiation error received");
5079 if let Some(tx_est) = self.session_initiations.remove(&challenge) {
5080 if let Err(error) = tx_est.try_send(Err(error_type)) {
5081 error!(%error, "could not send session error message");
5082 return Err(SessionManagerError::other(error).into());
5083 }
5084 error!(%challenge, "session establishment error received");
5085 } else {
5086 error!(
5087 %challenge,
5088 "session establishment attempt expired before error could be delivered"
5089 );
5090 }
5091 }
5092 ErrorIdentifier::SessionId(session_id) => {
5093 error!(
5094 %session_id, %reason,
5095 "received post-establishment session error — closing session"
5096 );
5097 // Best-effort close; the session may have already been removed.
5098 self.close_session(&session_id);
5099 }
5100 }
5101
5102 #[cfg(all(feature = "telemetry", not(test)))]
5103 METRIC_RECEIVED_SESSION_ERRS.increment(&[&reason.to_string()]);
5104
5105 Ok(())
5106 }
5107
5108 #[tracing::instrument(level = "trace", skip(self, msg))]
5109 async fn handle_keep_alive(&self, msg: KeepAliveMessage<SessionId>) -> errors::Result<()> {
5110 let session_id = msg.session_id;
5111 if let Some(session_slot) = self.sessions.get(&session_id) {
5112 trace!(?session_id, "received keep-alive message");
5113 match &session_slot.routing_opts {
5114 // Session is outgoing - keep-alive was received from the Exit
5115 DestinationRouting::Forward { .. } => {
5116 if msg.flags.contains(KeepAliveFlag::BalancerState) && !session_slot.surb_mgmt.is_disabled() {
5117 // Record the reported level and let it infer the counterparty's real store
5118 // capacity from a plateau. Every report is fed in, including one that repeats
5119 // the last value -- a level that stops rising while we are still minting is
5120 // exactly the plateau signal, so this must not dedup equal reports.
5121 let changed = session_slot.surb_mgmt.buffer_level() != msg.additional_data;
5122 session_slot.surb_mgmt.observe_reported_level(msg.additional_data);
5123 if changed {
5124 debug!(%session_id, surb_level = msg.additional_data, "keep-alive updated SURB buffer size from the Exit");
5125 }
5126 }
5127
5128 // Increase the number of consumed SURBs in the estimator
5129 session_slot
5130 .surb_estimator
5131 .consumed
5132 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5133 #[cfg(feature = "telemetry")]
5134 telemetry::record_session_surb_consumed(&session_id, 1);
5135 }
5136 // Session is incoming - keep-alive was received from the Entry
5137 DestinationRouting::Return(_) => {
5138 // Allow updating SURB balancer target based on the received Keep-Alive message
5139 if msg.flags.contains(KeepAliveFlag::BalancerTarget)
5140 && msg.additional_data > 0
5141 && !session_slot.surb_mgmt.is_disabled()
5142 && session_slot.surb_mgmt.controller_bounds().target() != msg.additional_data
5143 {
5144 // Update the target buffer size as sent to us from the Entry
5145 session_slot
5146 .surb_mgmt
5147 .target_surb_buffer_size
5148 .store(msg.additional_data, std::sync::atomic::Ordering::Relaxed);
5149 // Update maximum SURBs per second based on the new target
5150 session_slot.surb_mgmt.max_surbs_per_sec.store(
5151 msg.additional_data / self.cfg.minimum_surb_buffer_duration.as_secs(),
5152 std::sync::atomic::Ordering::Relaxed,
5153 );
5154 debug!(%session_id, target_surb_buffer_size = msg.additional_data, "keep-alive updated SURB balancer target buffer size from the Entry");
5155 }
5156
5157 // Increase the number of received SURBs in the estimator.
5158 let produced = msg.max_surbs()? as u64;
5159 session_slot
5160 .surb_estimator
5161 .produced
5162 .fetch_add(produced, std::sync::atomic::Ordering::Relaxed);
5163 #[cfg(feature = "telemetry")]
5164 telemetry::record_session_surb_produced(&session_id, produced);
5165 }
5166 }
5167 } else {
5168 debug!(%session_id, "received keep-alive request for an unknown session");
5169 }
5170
5171 Ok(())
5172 }
5173
5174 /// Handled by the Exit, when Entry replies with PIX commitment
5175 #[tracing::instrument(level = "debug", skip(self, msg))]
5176 async fn handle_ssa_commit(
5177 &self,
5178 pseudonym: HoprPseudonym,
5179 msg: SsaClientCommitmentMessage<SessionId, HoprPixGroupElement, HoprPixCommitmentProof>,
5180 ) -> errors::Result<()> {
5181 let Some(pix_toolbox) = self.pix_toolbox.get().cloned() else {
5182 return Err(SessionManagerError::UnsupportedMessage.into());
5183 };
5184
5185 let session_id = msg.session_id;
5186
5187 if pseudonym != session_id {
5188 error!(%pseudonym, %msg.session_id, "received SSA client commitment for a different session");
5189 return Err(SessionManagerError::NonExistingSession.into());
5190 }
5191
5192 let Some(session_slot) = self.sessions.get(&session_id) else {
5193 return Err(SessionManagerError::NonExistingSession.into());
5194 };
5195
5196 // See if we haven't received an SSA commitment for a Session that we did not register as PIX-capable
5197 let Some(ssa_state) = session_slot.current_ssa_state.get() else {
5198 return Err(SessionManagerError::Other(anyhow::anyhow!("no SSA state for session {session_id}")).into());
5199 };
5200 let quota_per_ssa = ssa_state.quota_per_ssa();
5201
5202 let ssa_id = SsaId::new(pseudonym, msg.ssa_index);
5203
5204 // Decode the accompanying proof of knowledge, if the message carries one. A malformed proof
5205 // is rejected here rather than being passed on as absent, so that it cannot be mistaken for
5206 // a peer that simply did not send one.
5207 let commitment_proof = msg
5208 .commitment_proof
5209 .map(|proof| proof.try_into_pix_proof())
5210 .transpose()
5211 .map_err(SessionManagerError::other)?;
5212
5213 // Insert the newly received coefficients into the SSA Reconstructor
5214 let pix_toolbox_clone = pix_toolbox.clone();
5215 let ssa_client_commitment_state = hopr_utils::parallelize::cpu::spawn_blocking(
5216 move || {
5217 pix_toolbox_clone
5218 .share_processor
5219 .insert_coefficient_commitments(
5220 ssa_id,
5221 msg.coefficient_index,
5222 commitment_proof,
5223 msg.coefficient_commitments.into_iter().map(|(k, v)| (k, v.0)),
5224 )
5225 .map_err(SessionManagerError::PixError)
5226 },
5227 "ssa commitment reconstructor",
5228 )
5229 .await
5230 .map_err(|_| {
5231 SessionManagerError::Other(anyhow::anyhow!(
5232 "failed to insert SSA coefficients into the SSA reconstructor"
5233 ))
5234 })??;
5235
5236 // A verifiable commitment is what starts the deposit clock, so tell the supervisor before
5237 // the observer below can report anything against it.
5238 //
5239 // An *incomplete* one is reported too, and per message: that is what arms the supervisor's
5240 // re-request timer and keeps pushing it out while the burst is still arriving, so it fires
5241 // only once delivery has actually stalled. It is also the only evidence that the Entry
5242 // answered at all, which is what separates a cycle worth re-asking for from one where
5243 // nothing was ever delivered and there is no scope to name.
5244 if let Some(supervisor) = session_slot.pix_supervisor.get() {
5245 let event = if ssa_client_commitment_state.is_verifiable {
5246 SessionPixEvent::CommitmentVerified(ssa_id)
5247 } else {
5248 SessionPixEvent::CommitmentProgress(ssa_id)
5249 };
5250 if supervisor.send_event(event).await.is_err() {
5251 error!(%session_id, %ssa_id, "pix supervisor is no longer accepting events");
5252 }
5253 }
5254
5255 if ssa_client_commitment_state.deposit_address_first_encountered
5256 && let Some(deposit_address) = ssa_client_commitment_state.ssa_deposit_address
5257 {
5258 // Inside the guard on purpose: every other `SsaCommit` of a cycle takes the other branch,
5259 // so allocating this before the `if` built and dropped a channel per message.
5260 let (deposit_done_tx, deposit_done_rx) =
5261 futures::channel::mpsc::channel::<(PixAddressId, hopr_api::HoprBalance)>(10);
5262 // Report deposits for as long as they arrive rather than waiting for the first one and
5263 // stopping: the pool may report more than once for a cycle, and a first message carrying
5264 // a zero balance asserts nothing, so the one that funds the cycle need not be the first.
5265 // The supervisor owns the deadline, so the observer carries none — a timeout here would
5266 // have been a second authority racing the first.
5267 let supervisor = session_slot.pix_supervisor.get().cloned();
5268 session_slot.abort_handles.lock().insert(
5269 SessionHandles::PixDepositObserver(ssa_id.ssa_index().get()),
5270 hopr_utils::spawn_as_abortable!(async move {
5271 let Some(supervisor) = supervisor else {
5272 // No supervisor: a Session that negotiated PIX always has one, so this is
5273 // only reachable if it died between the check above and here.
5274 return;
5275 };
5276 let mut confirmations = deposit_done_rx.filter(|(evt_id, _)| {
5277 futures::future::ready(
5278 evt_id.ssa_index() == ssa_id.ssa_index() && &evt_id.session_id() == ssa_id.pseudonym(),
5279 )
5280 });
5281 while let Some((_, amount)) = confirmations.next().await {
5282 info!(%session_id, %ssa_id, %amount, "ssa deposit confirmed");
5283 if supervisor
5284 .send_event(SessionPixEvent::DepositConfirmed { ssa_id, amount })
5285 .await
5286 .is_err()
5287 {
5288 return;
5289 }
5290 }
5291 // The upper layer dropped the sender without ever confirming, so no deposit is
5292 // coming. Saying so is better than letting the supervisor wait out its deadline.
5293 warn!(%session_id, %ssa_id, "deposit channel closed without confirmation");
5294 let _ = supervisor
5295 .send_event(SessionPixEvent::DepositObserverClosed(ssa_id))
5296 .await;
5297 }),
5298 );
5299
5300 // Notify upstream that deposit is needed
5301 pix_toolbox
5302 .pix_events
5303 .try_send(HoprSessionOutPixEvent::DepositNeeded(
5304 AgreedSsaQuota {
5305 ssa_id,
5306 deposit_address,
5307 quota_per_ssa,
5308 // Handed back to the pool that produced it, now that the address it pays for
5309 // is known. Consumed here: this branch is reached once per cycle.
5310 deposit_data: ssa_state.take_sent_deposit_data(&session_id, ssa_id.ssa_index()),
5311 },
5312 deposit_done_tx,
5313 ))
5314 .map_err(|_| {
5315 SessionManagerError::other(anyhow::anyhow!("failed to send pix event for needed deposit"))
5316 })?;
5317 info!(%ssa_id, %deposit_address, quota_per_ssa, "retrieved first client SSA commitment and deposit address");
5318 }
5319
5320 Ok(())
5321 }
5322
5323 /// Tells the Exit that its [`SsaServerCommitmentMessage`] was refused, and tears down this half
5324 /// of the Session.
5325 ///
5326 /// Without the notice the refusal is invisible to the Exit, and it has no way to recover from it.
5327 /// Its supervisor registered an Exit commitment per requested index and armed each cycle's
5328 /// commitment deadline the moment the request went out; it will never receive an `SsaCommit`, and
5329 /// nothing can make it re-ask, because a new `RequestSsa` only comes from share-recovery events and
5330 /// no shares exist for a cycle the Entry never committed to. So it serves the Session
5331 /// unincentivized for the whole batch-scaled `max_ssa_delivery_time` and then closes it as
5332 /// `CommitmentTimeout` — a reason that names the clock rather than the refusal, on the one node
5333 /// whose operator can act on it.
5334 ///
5335 /// Closing this half too is what stops the Entry sitting on a Session that can never make PIX
5336 /// progress: a refusal is terminal either way, since the Exit re-derives every request from state
5337 /// that cannot drift within a Session, so a later one would be refused identically. Left alone the
5338 /// slot would survive until the idle timeout.
5339 ///
5340 /// No new capability is handed to an attacker by closing on a refusal: an `SsaRequest` only reaches
5341 /// here Sphinx-authenticated and with `pseudonym == session_id`, so only the Exit can produce one —
5342 /// and the Exit can already close the Session whenever it likes.
5343 async fn refuse_ssa_request(&self, session_id: SessionId, routing: DestinationRouting) {
5344 self.notify_session_error(
5345 session_id,
5346 routing,
5347 StartErrorReason::UnacceptablePixParams,
5348 "session error due to a refused SSA request",
5349 )
5350 .await;
5351
5352 if self.close_session(&session_id) {
5353 error!(%session_id, "closed session after refusing the Exit's SSA request");
5354 }
5355 }
5356
5357 /// Handled by the Entry, when the Exit says it never received parts of a commitment this node
5358 /// already published.
5359 ///
5360 /// Re-sends the named polynomial commitments and nothing else. Emits no
5361 /// [`ReadyToDeposit`](HoprSessionOutPixEvent::ReadyToDeposit): the deposit address of a cycle is
5362 /// fixed when it is first committed, and the caller was told it then, so announcing it again
5363 /// would invite a second deposit against one quota.
5364 ///
5365 /// A failure here is reported to the caller of the Start-message dispatcher and otherwise
5366 /// dropped. It is deliberately not [`refuse_ssa_request`](Self::refuse_ssa_request), which closes
5367 /// the Session: an Exit that asks for a cycle this node cannot serve is asking for something
5368 /// already lost, and its own commitment deadline ends the Session either way — whereas closing on
5369 /// a request that merely raced our own state would throw away a Session that was about to work.
5370 ///
5371 /// Two separate bounds keep this from being a packet-amplification lever, because one request may
5372 /// name a whole cycle's polynomials and the answer to that is the entire commitment burst again.
5373 /// The generator bounds how often it answers for *one* cycle
5374 /// ([`MAX_COMMITMENT_RETRANSMISSIONS`](hopr_protocol_pix::MAX_COMMITMENT_RETRANSMISSIONS)); this
5375 /// function bounds how many cycles one request may set going. Neither stands in for the other —
5376 /// the first bounds repeats, the second bounds breadth.
5377 async fn handle_ssa_recommit(
5378 &self,
5379 pseudonym: HoprPseudonym,
5380 session_slot: &SessionSlot,
5381 msg: SsaServerCommitmentMessage<SessionId, HoprPixGroupElement, HoprPixDepositPayload>,
5382 ) -> errors::Result<()> {
5383 // The wire bounds the *runs* a request carries, not the number of cycles they are spread
5384 // over — one run of 8 bytes can name a whole cycle — so without this a single packet names
5385 // every retained cycle and is answered with a full commitment burst for each. The generator's
5386 // per-cycle attempt cap does not bound that: it bounds repeats of one cycle, not breadth
5387 // across cycles.
5388 //
5389 // The same cap as the new-SSA path, so repairing a batch can never cost more than committing
5390 // to it did, and the Exit's own requests name a single cycle. Dropped rather than refused
5391 // with `refuse_ssa_request`: the peer is already inside its own commitment deadline and
5392 // learns nothing from a refusal it does not learn from that, and replying at all would hand
5393 // back some of the amplification the request was reaching for.
5394 let max_ssas_per_request = self.cfg.max_ssas_per_ssa_request;
5395 if msg.missing.len() > max_ssas_per_request {
5396 return Err(SessionManagerError::Unacceptable(format!(
5397 "Exit asked for retransmissions across {} SSA cycles in a single request, at most \
5398 {max_ssas_per_request} allowed",
5399 msg.missing.len()
5400 ))
5401 .into());
5402 }
5403
5404 let pix_toolbox = self.pix_toolbox.get().cloned().ok_or(SessionManagerError::NotStarted)?;
5405 let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
5406 let session_id = msg.session_id;
5407
5408 for (ssa_index, runs) in msg.missing {
5409 // Rebuilt from the bytes that went out the first time, so the peer's already-filled slots
5410 // see a re-delivery rather than a contradiction.
5411 let pix_toolbox_clone = pix_toolbox.clone();
5412 let commitment = hopr_utils::parallelize::cpu::spawn_blocking(
5413 move || pix_toolbox_clone.share_generator.recommit(&pseudonym, ssa_index, runs),
5414 "client_ssa_recommitment",
5415 )
5416 .await
5417 .map_err(SessionManagerError::other)?
5418 .map_err(SessionManagerError::PixError)?;
5419
5420 let commitment_msgs =
5421 SsaClientCommitmentMessage::new_multiple(session_id, commitment).map_err(SessionManagerError::other)?;
5422 let num_messages = commitment_msgs.len();
5423
5424 for commitment_msg in commitment_msgs {
5425 send_via_msg_sender(
5426 &mut msg_sender,
5427 session_slot.routing_opts.clone(),
5428 HoprStartProtocol::SsaCommit(commitment_msg),
5429 "re-sent client SSA commitment message",
5430 )
5431 .await?;
5432 }
5433
5434 info!(%session_id, %ssa_index, num_messages, "re-sent the requested SSA commitment parts");
5435 }
5436
5437 Ok(())
5438 }
5439
5440 /// Handled by the Entry, when the Exit sends PIX initiation request
5441 #[tracing::instrument(level = "debug", skip(self, msg))]
5442 async fn handle_ssa_request(
5443 &self,
5444 pseudonym: HoprPseudonym,
5445 msg: SsaServerCommitmentMessage<SessionId, HoprPixGroupElement, HoprPixDepositPayload>,
5446 ) -> errors::Result<()> {
5447 let Some(pix_toolbox) = self.pix_toolbox.get().cloned() else {
5448 return Err(SessionManagerError::UnsupportedMessage.into());
5449 };
5450
5451 if pseudonym != msg.session_id {
5452 error!(%pseudonym, %msg.session_id, "received SSA server commitment for a different session");
5453 return Err(SessionManagerError::NonExistingSession.into());
5454 }
5455
5456 // The SsaRequest can arrive before new_session() or handle_incoming_session_initiation
5457 // has finished allocating the session slot, since both SessionEstablished and SsaRequest
5458 // are sent by the Exit back-to-back and processed concurrently by the Start protocol handler.
5459 // Instead of busy-looping, await the allocation notification for this specific SessionId.
5460 let session_slot = {
5461 use std::collections::hash_map::Entry;
5462 let session_id = msg.session_id;
5463 let waiter_deadline = Instant::now() + Duration::from_secs(30);
5464 loop {
5465 if Instant::now() >= waiter_deadline {
5466 error!(%session_id, "session slot waiter deadline exceeded");
5467 return Err(SessionManagerError::NonExistingSession.into());
5468 }
5469 // Optimistic cache check
5470 if let Some(slot) = self.sessions.get(&session_id) {
5471 break slot;
5472 }
5473
5474 // Register a waiter under the lock, then recheck the cache to avoid the
5475 // TOCTOU race between the initial cache check and waiter registration.
5476 let (tx, rx) = oneshot::channel::<()>();
5477 {
5478 let mut map = self.slot_allocated.lock().unwrap_or_else(|e| e.into_inner());
5479 // Recheck while holding the lock: the slot may have been inserted
5480 // between the optimistic check and now. If so, don't register.
5481 if self.sessions.get(&session_id).is_some() {
5482 drop(map);
5483 continue;
5484 }
5485 map.entry(session_id).or_default().push(tx);
5486 }
5487
5488 let timeout = futures_time::time::Duration::from(Duration::from_millis(1000));
5489 match rx.timeout(timeout).await {
5490 Ok(Ok(())) => {
5491 // Notified — recheck the cache
5492 continue;
5493 }
5494 _ => {
5495 // Timeout or the sender was dropped (cancelled). Clean up our
5496 // waiter entry to avoid unbounded accumulation in the map.
5497 let mut map = self.slot_allocated.lock().unwrap_or_else(|e| e.into_inner());
5498 if let Entry::Occupied(mut e) = map.entry(session_id) {
5499 e.get_mut().retain(|w| !w.is_canceled());
5500 if e.get().is_empty() {
5501 e.remove();
5502 }
5503 }
5504 drop(map);
5505
5506 // Final cache recheck before giving up — the slot might have
5507 // been inserted while we were cleaning up.
5508 if let Some(slot) = self.sessions.get(&session_id) {
5509 break slot;
5510 }
5511
5512 error!(%session_id, "session slot not found after awaiting allocation");
5513 return Err(SessionManagerError::NonExistingSession.into());
5514 }
5515 }
5516 }
5517 };
5518
5519 debug!(
5520 num_server_commitments = msg.commitments.len(),
5521 "received Exit SSA commitments"
5522 );
5523
5524 // Cap how many SSAs a single request may ask us to commit to.
5525 //
5526 // The wire format alone permits `MAX_SSAS_PER_REQUEST` (27) commitments per message, and
5527 // every accepted entry costs a full `new_ssa_commitment` (hundreds of thousands of EC
5528 // commitments) plus thousands of outbound `SsaCommit` packets, and emits its own
5529 // `ReadyToDeposit` — i.e. its own on-chain deposit. Without a cap, a single packet from a
5530 // misbehaving Exit amplifies into minutes of Entry CPU, a large packet burst, and up to 27
5531 // simultaneous deposits bounded only by the per-deposit allocation limit.
5532 //
5533 // Rejecting the whole message rather than the surplus is deliberate: a truncated batch would
5534 // leave the Exit holding reconstructor cycles for indices it will never receive commitments
5535 // for, which its own kill switch then has to clean up.
5536 let max_ssas_per_request = self.cfg.max_ssas_per_ssa_request;
5537 if msg.commitments.len() > max_ssas_per_request {
5538 let error = SessionManagerError::Unacceptable(format!(
5539 "Exit requested {} SSA commitments in a single request, at most {max_ssas_per_request} allowed",
5540 msg.commitments.len()
5541 ));
5542 self.refuse_ssa_request(msg.session_id, session_slot.routing_opts.clone())
5543 .await;
5544 return Err(error.into());
5545 }
5546
5547 let Some(our_params) = session_slot.current_ssa_state.get().map(|s| s.params) else {
5548 return Err(
5549 SessionManagerError::Other(anyhow::anyhow!("no SSA state for session {}", msg.session_id)).into(),
5550 );
5551 };
5552
5553 // The Entry enforces that the Exit's SSA parameters match exactly the ones we offered in the
5554 // Session Initiation message. Negotiation (accepting an Exit-chosen quota within our
5555 // bounds) is not implemented, so any mismatch is rejected.
5556 //
5557 // The whole quadruple is compared rather than the scalar quota it implies. Quota equality
5558 // was once argued to be sufficient — the Exit does not pick the dimensions independently, so
5559 // a matching product implied matching `(polys, shares)` from any Exit running unmodified
5560 // code. The quota now prices three of the four, so it is no longer blind to the surplus,
5561 // but it is still a product: it cannot tell `polys x threshold` from a transposition of the
5562 // two, and those are not interchangeable to the protocol. Comparing the params is both
5563 // stricter and simpler, and costs nothing now that all four travel together.
5564 //
5565 // The fourth, the curve suite, is why the Entry needs no separate curve check of its own:
5566 // the Exit refused a foreign suite before it sent this message, and an Exit that echoed a
5567 // different one back than the Entry offered fails right here.
5568 //
5569 // Malformed params never reach this comparison as a mismatch: `dimensions()` fails first,
5570 // and that failure takes the same refusal path, so the Exit is told either way rather than
5571 // being left waiting on a dropped packet.
5572 let server_params = match msg.dimensions() {
5573 Ok(params) => params,
5574 Err(error) => {
5575 self.refuse_ssa_request(msg.session_id, session_slot.routing_opts.clone())
5576 .await;
5577 return Err(
5578 SessionManagerError::Unacceptable(format!("Exit sent malformed PIX parameters: {error}")).into(),
5579 );
5580 }
5581 };
5582 if our_params != server_params {
5583 let error = SessionManagerError::Unacceptable(format!(
5584 "Exit sent unacceptable PIX parameters {server_params} (ours are {our_params})"
5585 ));
5586 self.refuse_ssa_request(msg.session_id, session_slot.routing_opts.clone())
5587 .await;
5588 return Err(error.into());
5589 }
5590 // A request naming missing commitment parts asks for nothing new — see
5591 // `SsaServerCommitmentMessage` — and none of what follows may run for it.
5592 //
5593 // That placement is the whole safety argument for reusing this message. The successor gate
5594 // below would refuse an index that is *already* being served, which is every index a
5595 // retransmission can name; the served-packet gate prices service against a batch nobody is
5596 // asking for; and `new_ssa_commitment` rejects an index at or below the watermark, which
5597 // again is every index this can name. Above this line is what a retransmission does need:
5598 // the pseudonym check, the session slot, and the parameter comparison.
5599 if !msg.missing.is_empty() {
5600 return self.handle_ssa_recommit(pseudonym, &session_slot, msg).await;
5601 }
5602
5603 let quota_per_ssa = pix_params_to_quota(&our_params);
5604
5605 // Everything from here to the end of the batch is serialised per pseudonym. Start messages run
5606 // under `for_each_concurrent`, so the successor gate below would otherwise be read by several
5607 // requests before any of them advanced it, and every one would pass. See `ssa_request_locks`.
5608 let request_lock = self
5609 .ssa_request_locks
5610 .get_with(pseudonym, async { Arc::new(futures::lock::Mutex::new(())) })
5611 .await;
5612 let _request_guard = request_lock.lock().await;
5613
5614 // Successor gate, the Entry's half of the one in `SessionPixSupervisor`. A correct Exit asks
5615 // for the next batch when the *last* cycle of the current one is nearly recovered, by which
5616 // point we have long been emitting that cycle's shares. An Exit that asks earlier is asking
5617 // for deposits it cannot have earned, so nothing here commits and nothing is deposited.
5618 //
5619 // Two conditions, and the second is what the first alone cannot give. Emission must have
5620 // reached the last committed cycle — which, with the window clamped to one cycle, means every
5621 // earlier one is exhausted — *and* it must be far enough into that cycle. Checking only the
5622 // index admitted a successor batch on the last cycle's very first share, i.e. ~0 % of the way
5623 // through the batch rather than the ~86 % at which a conforming Exit asks. That is nearly a
5624 // whole cycle of unearned deposits, on a gate whose entire purpose is to prevent them.
5625 //
5626 // The boundary is derived rather than guessed — see `min_emission_for_early_recovery`, which
5627 // accounts for the Exit counting *polynomials* and for emission running in lockstep windows
5628 // that spend their whole surplus before the next window starts. A flat fraction of the cycle
5629 // gets this badly wrong in the unsafe direction: 0.85/1.5 is 57 %, against a true boundary of
5630 // 86.4 % at the deployed dimensions.
5631 //
5632 // Being early is refused rather than fatal, and deliberately not via `refuse_ssa_request`,
5633 // which closes the Session: that message is dropped and the Session left running. A correct
5634 // Exit never lands there; one that does has its own deadline for the cycles it already
5635 // allocated, and it can reach them without us having paid for anything. Lost generator state
5636 // is the one arm that *is* fatal, for the reason given at it.
5637 //
5638 // First index rather than each: `commitments` is a `BTreeMap`, so the lowest index leads, and
5639 // checking it before the loop is also what stops a batch that fails partway from having
5640 // already emitted `ReadyToDeposit` for its earlier members.
5641 //
5642 // Computed at the protocol floor rather than at our own reconstructor's threshold. The value
5643 // that decides when a correct Exit asks is *its* setting, which does not travel on the wire;
5644 // gating on ours refused a peer configured lower, silently, with no retry path — two valid
5645 // configurations that could not talk. See `MIN_EARLY_RECOVERY_THRESHOLD`, which every Exit is
5646 // held to by `validate_pix_supervision` and which is therefore the earliest any conforming
5647 // peer can ask.
5648 let min_emitted = hopr_protocol_pix::min_emission_for_early_recovery(
5649 &our_params,
5650 hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD,
5651 );
5652 match pix_toolbox.share_generator.emission_progress(&pseudonym) {
5653 Some(progress) if !progress.is_serving_last_committed() || progress.front_emitted < min_emitted => {
5654 let asked_first = msg.commitments.keys().next().copied();
5655 let error = SessionManagerError::Unacceptable(format!(
5656 "Exit asked for SSAs from {asked_first:?} while emission has reached {:?} ({} of {min_emitted} \
5657 shares needed for an early-recovery signal) of the batch committed up to {}",
5658 progress.highest_emitted, progress.front_emitted, progress.last_committed
5659 ));
5660 warn!(session_id = %msg.session_id, %error, "refused an early SSA request");
5661 return Err(error.into());
5662 }
5663 // No generator state, but this Session has committed before: the entry was discarded
5664 // under us and the gate above has nothing left to measure.
5665 //
5666 // Absent state is otherwise the ordinary opening batch, which is why the gate treats it as
5667 // admissible — and that is the whole of the hole this closes. The generator keeps its
5668 // per-pseudonym entry in a cache with an idle retention, refreshed by share *emission*; a
5669 // Session kept alive on KeepAlives alone while the Entry sends nothing outlives it. An Exit
5670 // that arranges exactly that gets the successor gate deleted rather than merely relaxed —
5671 // no emission boundary, and no monotonic index either, since the discarded entry took the
5672 // high-watermark with it — and can then farm a fresh deposit per retention period.
5673 //
5674 // Terminal rather than a dropped message. The polynomials for the committed cycles went
5675 // with the entry, so `next_share` yields nothing and no share of them will ever be emitted:
5676 // whatever the Exit deposited against them is already unrecoverable and this half of the
5677 // Session can make no further PIX progress. Refusing quietly would leave the Exit to
5678 // discover that by timing out on commitments we have decided never to send.
5679 None if session_slot
5680 .current_ssa_state
5681 .get()
5682 .and_then(SessionSsaState::committed_watermark)
5683 .is_some() =>
5684 {
5685 let error = SessionManagerError::Unacceptable(format!(
5686 "generator state for {pseudonym} was discarded while the Session was live; the cycles committed \
5687 up to {:?} can no longer be served",
5688 session_slot
5689 .current_ssa_state
5690 .get()
5691 .and_then(SessionSsaState::committed_watermark)
5692 ));
5693 warn!(session_id = %msg.session_id, %error, "refused an SSA request against lost generator state");
5694 self.refuse_ssa_request(msg.session_id, session_slot.routing_opts.clone())
5695 .await;
5696 return Err(error.into());
5697 }
5698 _ => {}
5699 }
5700
5701 // Second half of the successor gate, and the one that measures the Exit rather than us.
5702 //
5703 // Emission above is this node's own work: it counts shares handed to the local packet
5704 // pipeline by `create_surb_for_path`, a consumption that is not even rolled back when the
5705 // rest of the packet build fails. An Exit that requests, is funded, and then returns nothing
5706 // still walks that counter forward for as long as we keep sending, so on its own it prices
5707 // deposits against work we did to ourselves.
5708 //
5709 // What is checked here is service that actually arrived. A share is encrypted with the first
5710 // relayer's challenge solution and rides a return SURB, so the Exit can only decrypt it by
5711 // *using* that SURB — one returned packet is one SURB consumed is one share unlocked. The
5712 // Exit cannot inflate this without unlocking exactly as many shares, which advances the
5713 // recovery it is claiming to have made. Nothing it reports is trusted.
5714 //
5715 // Skipped before the first commitment: the opening batch is the one nothing has been paid
5716 // for yet, and there is no service to have been rendered against it.
5717 if let Some(state) = session_slot.current_ssa_state.get()
5718 && let Some(watermark) = state.committed_watermark()
5719 {
5720 let shares_per_cycle = our_params.polys_per_ssa() as u64 * our_params.emitted_shares_per_poly() as u64;
5721 let target = (watermark.get() as u64 - 1) * shares_per_cycle + min_emitted;
5722
5723 // Discounted by exactly the loss the surplus insures against. The Exit unlocks a share
5724 // when the *first relayer* acknowledges, which is upstream of us, so every packet lost
5725 // after that point is progress it legitimately has and we cannot see. Demanding the
5726 // undiscounted figure would refuse conforming Exits on any lossy path; an Exit losing
5727 // more than the surplus covers could not have reconstructed the cycle anyway.
5728 //
5729 // `u128` for the multiplication only — at the extremes of the accepted ranges the
5730 // numerator overflows `u64`. The quotient cannot, since the ratio is at most one.
5731 let required = (target as u128 * our_params.shares_per_poly() as u128
5732 / our_params.emitted_shares_per_poly() as u128) as u64;
5733
5734 let served = |slot: &SessionSlot| {
5735 state.served_since_first_commit(slot.returned_packets.load(std::sync::atomic::Ordering::Relaxed))
5736 };
5737 let mut observed = served(&session_slot);
5738
5739 if observed < required {
5740 // A conforming Exit asks the instant its reconstructor crosses the threshold, and the
5741 // request travels the same mixed path as the packets that earned it — so it can
5742 // overtake the last few of them. Refusing that is not a refusal at all: `RequestSsa`
5743 // is emitted once per index and never retried, so the Exit sits in
5744 // `AwaitingCommitment` until `max_ssa_delivery_time` and then closes the Session.
5745 // A short wait costs nothing and saves the Session.
5746 //
5747 // Entered only for a near miss, and that is what keeps the wait from being a lever:
5748 // this handler runs under a bounded `for_each_concurrent` and holds the
5749 // per-pseudonym request lock, so an Exit must already have returned all but one
5750 // emission window of what it owes to occupy either. Anything further out is refused
5751 // on the spot, at no cost to us.
5752 //
5753 // One window rather than a fraction of `required`: it is the unit emission advances
5754 // in, so a shortfall below it is genuinely in-flight, and a percentage would round to
5755 // zero at exactly the small dimensions where this matters most.
5756 let near_miss = observed > 0 && required - observed <= hopr_protocol_pix::SHARE_EMISSION_WINDOW as u64;
5757 if near_miss {
5758 let deadline = Instant::now() + SSA_SUCCESSOR_SERVICE_WAIT;
5759 while observed < required && Instant::now() < deadline {
5760 hopr_utils::runtime::prelude::sleep(SSA_SUCCESSOR_SERVICE_POLL).await;
5761 observed = served(&session_slot);
5762 }
5763 }
5764 }
5765
5766 if observed < required {
5767 let error = SessionManagerError::Unacceptable(format!(
5768 "Exit asked for SSAs having returned {observed} of the {required} packets its batch committed up \
5769 to {watermark} has been paid for"
5770 ));
5771 warn!(session_id = %msg.session_id, %error, "refused an under-served SSA request");
5772 return Err(error.into());
5773 }
5774 }
5775
5776 let mut msg_sender = self.msg_sender.get().cloned().ok_or(SessionManagerError::NotStarted)?;
5777 let session_id = msg.session_id;
5778
5779 // The server can theoretically send multiple SSA commitments asking us to make the equal
5780 // number of client commitments and deposits, and the batch is all-or-nothing: either every
5781 // member gets a commitment and a `ReadyToDeposit`, or none does.
5782 //
5783 // Hence three phases rather than one loop. Interleaving them meant a batch whose *second*
5784 // exit commitment failed to decode had already sent the first member's `SsaCommit` burst and
5785 // emitted its `ReadyToDeposit` — an on-chain deposit instruction — before the failure was
5786 // reached. The Exit, whose own request was rejected as a whole, has no cycle to spend it on.
5787 //
5788 // The server is authoritative in giving the ssa_index; the client only verifies that it is
5789 // strictly monotonic. That monotonicity is enforced inside `new_ssa_commitment` in phase two,
5790 // which rejects any `ssa_index` that is `<=` the last one generated for this pseudonym with
5791 // `PixError::InvalidInput` (see `SsaShareGenerator::new_ssa_commitment`). Because that call
5792 // happens in a phase before anything is published, a stale, duplicate, or out-of-order
5793 // `SsaRequest` cannot cause a deposit — the whole message is rejected first. The
5794 // per-pseudonym baseline lives in the generator's `polynomials` cache (30-min idle TTL,
5795 // refreshed on every use), so it persists for the life of an active session. Gaps (an index
5796 // strictly greater than the last, but not the immediate successor) are allowed by design,
5797 // since the Exit may advance by more than one SSA at a time.
5798
5799 // Phase 1 — validate. Decoding is the only step that consumes attacker-supplied bytes:
5800 // `try_into_pix_group` decompresses the point and rejects anything outside the prime-order
5801 // subgroup. Doing every one of them up front is what makes a malformed later member cost
5802 // nothing, and it is cheap relative to phase two.
5803 // Taken out before the loop consumes `msg.commitments`, and drained as the loop goes: an SSA
5804 // is committed to once, so its deposit data is needed once.
5805 let mut deposit_payloads = msg.deposit_data;
5806
5807 let mut validated = Vec::with_capacity(msg.commitments.len());
5808 for (ssa_index, exit_commitment) in msg.commitments {
5809 trace!(ssa_index, "received Exit SSA commitment");
5810 match exit_commitment.try_into_pix_group() {
5811 Ok(point) => validated.push((ssa_index, point)),
5812 Err(error) => {
5813 // Terminal, like every other unacceptable-parameter case here. A peer that cannot
5814 // produce a valid group element is not going to produce one on a retry, and the
5815 // Exit would otherwise wait out `max_ssa_delivery_time` on commitments that are
5816 // never coming.
5817 let error = SessionManagerError::Unacceptable(format!(
5818 "Exit sent an undecodable SSA commitment for index {ssa_index}: {error}"
5819 ));
5820 self.refuse_ssa_request(session_id, session_slot.routing_opts.clone())
5821 .await;
5822 return Err(error.into());
5823 }
5824 }
5825 }
5826
5827 // Phase 2 — stage. Generates every client commitment and derives every deposit address,
5828 // publishing none of them. This is the expensive phase, and it still mutates the generator:
5829 // `new_ssa_commitment` appends to the per-pseudonym polynomial queue. Failing here therefore
5830 // leaves queued polynomials the Exit never learns of — wasted work rather than a leaked
5831 // deposit instruction, and the generator's own monotonic index keeps them from being mistaken
5832 // for a later cycle's.
5833 let mut staged = Vec::with_capacity(validated.len());
5834 for (ssa_index, exit_point) in validated {
5835 // Use the global `pix_toolbox.share_generator` to generate the client
5836 // commitment. The generator is shared with the packet pipeline's
5837 // `next_share`, so polynomials created here will be used when the
5838 // pipeline embeds PIX shares into return-path SURBs.
5839 //
5840 // The generator dimension (polys × threshold) must match what the
5841 // Exit's reconstructor expects — both are set from the session's
5842 // negotiated PIX params (pix_global_config on Entry → SsaRequest
5843 // params on Exit). If the client sends commitments that exceed the
5844 // Exit's expected dimensions, the Exit rejects them as InvalidInput.
5845 let pix_toolbox_clone = pix_toolbox.clone();
5846 let client_commitment = hopr_utils::parallelize::cpu::spawn_blocking(
5847 move || {
5848 pix_toolbox_clone
5849 .share_generator
5850 .new_ssa_commitment(&pseudonym, ssa_index)
5851 },
5852 "client_ssa_commitment",
5853 )
5854 .await
5855 .map_err(SessionManagerError::other)?
5856 .map_err(SessionManagerError::PixError)?;
5857
5858 // The generator has been advanced for this index, so the Session records it now rather
5859 // than after the publish phase. Recording it early is the safe direction: the watermark
5860 // only ever makes the successor gate stricter, and a batch abandoned between here and
5861 // publication has still moved the generator.
5862 if let Some(state) = session_slot.current_ssa_state.get() {
5863 state.note_committed(
5864 ssa_index,
5865 session_slot.returned_packets.load(std::sync::atomic::Ordering::Relaxed),
5866 );
5867 }
5868
5869 // Construct the full SSA by adding the client and exit commitments, getting the deposit address
5870 let full_ssa = client_commitment.ssa_commitment + exit_point;
5871 let deposit_address = HoprPixSpec::group_to_deposit_address(full_ssa).ok_or(SessionManagerError::other(
5872 anyhow::anyhow!("failed to convert SSA to deposit address"),
5873 ))?;
5874
5875 // Split the SSA client commitment into Start protocol commitment messages
5876 let commitment_msgs = SsaClientCommitmentMessage::new_multiple(session_id, client_commitment)
5877 .map_err(SessionManagerError::other)?;
5878 debug!(%ssa_index, count = commitment_msgs.len(), "generated client SSA commitment messages");
5879
5880 staged.push((ssa_index, deposit_address, commitment_msgs));
5881 }
5882
5883 // Phase 3 — publish. Nothing below can fail on the *content* of the request; only the
5884 // transport can, and a transport that has stopped accepting messages fails the Session
5885 // regardless of where in the batch it happens.
5886 for (ssa_index, deposit_address, commitment_msgs) in staged {
5887 // Send each commitment message into the message sender
5888 for commitment_msg in commitment_msgs {
5889 send_via_msg_sender(
5890 &mut msg_sender,
5891 session_slot.routing_opts.clone(),
5892 HoprStartProtocol::SsaCommit(commitment_msg),
5893 "client SSA commitment message",
5894 )
5895 .await?;
5896 }
5897
5898 debug!(%ssa_index, "all Entry SSA commitment messages were sent out");
5899
5900 // Notify the new SSA deposit address *after* all commitment messages have been
5901 // sent out successfully, so the deposit cannot begin before the Exit has the
5902 // complete commitment to reconstruct the deposit key.
5903 pix_toolbox
5904 .pix_events
5905 .try_send(HoprSessionOutPixEvent::ReadyToDeposit(AgreedSsaQuota {
5906 ssa_id: SsaId::new(pseudonym, ssa_index),
5907 deposit_address,
5908 quota_per_ssa,
5909 // Rebuilt rather than carried: the `id` is not on the wire, because this message
5910 // already says which Session and which SSA these bytes belong to.
5911 deposit_data: PixDepositData {
5912 id: PixAddressId::new(&pseudonym, ssa_index),
5913 data: deposit_payloads
5914 .remove(&ssa_index)
5915 .map(|payload| payload.0)
5916 .unwrap_or_default(),
5917 },
5918 }))
5919 .map_err(|_| SessionManagerError::other(anyhow::anyhow!("failed to notify new deposit ssa")))?;
5920 info!(%ssa_index, %deposit_address, quota_per_ssa, "generated client SSA commitment and deposit address");
5921 }
5922
5923 trace!(quota_per_ssa, "Exit commitment message has been fully processed");
5924 Ok(())
5925 }
5926}
5927
5928#[cfg(test)]
5929mod tests {
5930 use anyhow::{Context, anyhow};
5931 use futures::{AsyncWriteExt, channel::mpsc::UnboundedSender, future::BoxFuture, pin_mut};
5932 use hopr_api::types::{
5933 crypto::{keypairs::ChainKeypair, prelude::Keypair},
5934 crypto_random::Randomizable,
5935 internal::routing::SurbMatcher,
5936 primitive::prelude::Address,
5937 };
5938 use hopr_crypto_packet::prelude::{PacketSignal, PacketSignals};
5939 use hopr_protocol_pix::{SsaGeneratorConfig, SsaIndex, SsaReconstructorConfig};
5940 use hopr_protocol_start::{StartProtocol, StartProtocolDiscriminants};
5941 use hopr_utils::network_types::prelude::SealedHost;
5942 use moka::future::FutureExt;
5943 use tokio::time::timeout;
5944
5945 use super::*;
5946 use crate::{Capabilities, balancer::SurbBalancerConfig, types::SessionTarget};
5947
5948 /// A [`PixToolbox`] whose deposit-data requests are answered, for tests that do not read the
5949 /// PIX event stream themselves.
5950 ///
5951 /// An Exit cannot send an `SsaRequest` without the pool answering, so a test that only needs the
5952 /// request to *happen* would otherwise be asserting against `MissingDepositData`.
5953 fn pix_toolbox_with_pool(
5954 generator: Arc<SsaShareGenerator<HoprPixSpec>>,
5955 reconstructor: Arc<SsaReconstructor<HoprPixSpec>>,
5956 ) -> PixToolbox {
5957 let (toolbox, events) = PixToolbox::new(generator, reconstructor);
5958 // The answering task owns `events`, and lives until the toolbox is dropped. The forwarded
5959 // stream is discarded: these tests do not read PIX events.
5960 drop(crate::testing::answering_deposit_pool(events, |_| Vec::new()));
5961 toolbox
5962 }
5963
5964 /// `StartInitiation::additional_data` as an Entry offering these dimensions would send it.
5965 ///
5966 /// Tests go through the same packing production does. They used to write the shifts out by
5967 /// hand, which meant a change to the layout altered what every one of them was asserting
5968 /// without altering a single line of them — plain `u64` literals type-check against anything.
5969 fn pix_additional_data(polys_per_ssa: u16, shares_per_poly: u8, surplus_shares: u8) -> u128 {
5970 into_initiation_additional_data(
5971 PixParams::try_new(polys_per_ssa, shares_per_poly, surplus_shares, LOCAL_PIX_SUITE)
5972 .expect("test dimensions must be valid")
5973 .into_additional_data(0),
5974 )
5975 }
5976
5977 /// The default test dimensions: the smallest legal split, with the surplus the test generators
5978 /// below are configured with.
5979 fn small_pix_params() -> PixParams {
5980 PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE).expect("test dimensions must be valid")
5981 }
5982
5983 /// [`small_pix_params`] as an Entry offering them would pack them into `additional_data`.
5984 fn small_pix_additional_data() -> u128 {
5985 into_initiation_additional_data(small_pix_params().into_additional_data(0))
5986 }
5987
5988 /// Surplus used by the small test `SsaGeneratorConfig`s below. Non-zero and different from
5989 /// [`SsaGeneratorConfig::default`], so a value that failed to cross the wire is visible.
5990 const TEST_SURPLUS_SHARES: u8 = 1;
5991
5992 #[test]
5993 fn session_config_forwards_max_buffered_segments() {
5994 assert_eq!(
5995 SessionManagerConfig::default().max_buffered_segments,
5996 0,
5997 "default must leave the transport unbuffered"
5998 );
5999
6000 for segments in [0, 64] {
6001 let cfg = SessionManagerConfig {
6002 max_buffered_segments: segments,
6003 ..Default::default()
6004 };
6005 assert_eq!(
6006 session_config(&cfg, Capabilities::empty()).max_buffered_segments,
6007 segments
6008 );
6009 }
6010 }
6011
6012 /// The head-of-line bound must reach a session that cannot recover a missing frame, and must
6013 /// not reach one that can.
6014 ///
6015 /// Both halves matter. Without the first, a session with no retransmission holds every frame
6016 /// behind a gap for the full frame timeout, waiting for something that is never coming —
6017 /// measured on a cluster as 98.5 % of bytes arriving over the wire and 0.60 % reaching the
6018 /// application. Without the second, a session that *would* have retransmitted the gap instead
6019 /// abandons it, turning recoverable frames into loss.
6020 #[test]
6021 fn session_config_should_bound_the_gap_only_without_retransmission() {
6022 assert_eq!(
6023 SessionManagerConfig::default().max_frames_behind_gap,
6024 Some(256),
6025 "the default must bound the gap, or the stall stays in place unless opted out of"
6026 );
6027
6028 let cfg = SessionManagerConfig {
6029 max_frames_behind_gap: Some(8),
6030 ..Default::default()
6031 };
6032
6033 for reliable in [Capability::RetransmissionAck, Capability::RetransmissionNack] {
6034 assert_eq!(
6035 session_config(&cfg, reliable.into()).max_frames_behind_gap,
6036 None,
6037 "{reliable:?} can recover the gap, so the wait is productive and must be left alone"
6038 );
6039 }
6040
6041 for unreliable in [Capabilities::empty(), Capability::Segmentation.into()] {
6042 assert_eq!(
6043 session_config(&cfg, unreliable).max_frames_behind_gap,
6044 Some(8),
6045 "without retransmission the gap must be bounded"
6046 );
6047 }
6048 }
6049
6050 /// The right value tracks throughput x latency spread, which is a property of the *session*,
6051 /// not of the node: a bulk data session and a control session on the same node have entirely
6052 /// different reordering depths. A caller that knows its own traffic must be able to say so.
6053 #[test]
6054 fn a_session_should_be_able_to_override_the_nodes_gap_bound() {
6055 let node = SessionManagerConfig {
6056 max_frames_behind_gap: Some(256),
6057 ..Default::default()
6058 };
6059
6060 assert_eq!(
6061 session_config_with(&node, Capabilities::empty(), Some(16)).max_frames_behind_gap,
6062 Some(16),
6063 "the session's own value must win over the node default"
6064 );
6065 assert_eq!(
6066 session_config_with(&node, Capabilities::empty(), None).max_frames_behind_gap,
6067 Some(256),
6068 "saying nothing must inherit the node default"
6069 );
6070 assert_eq!(
6071 session_config_with(&node, Capabilities::empty(), Some(0)).max_frames_behind_gap,
6072 None,
6073 "zero disables the bound for this session, matching the env knob's semantics"
6074 );
6075 }
6076
6077 /// A per-session override must not be able to re-enable the bound where waiting is productive.
6078 #[test]
6079 fn a_session_override_should_not_reach_a_session_that_can_retransmit() {
6080 let node = SessionManagerConfig::default();
6081 assert_eq!(
6082 session_config_with(&node, Capability::RetransmissionAck.into(), Some(4)).max_frames_behind_gap,
6083 None,
6084 "retransmission can recover the gap, so no caller should be able to cut the wait short"
6085 );
6086 }
6087
6088 /// Disabling has to be reachable, since the bound trades reordering tolerance for latency and
6089 /// the right value is deployment-specific. `None` restores the previous behaviour exactly.
6090 #[test]
6091 fn session_config_should_allow_the_gap_bound_to_be_disabled() {
6092 let cfg = SessionManagerConfig {
6093 max_frames_behind_gap: None,
6094 ..Default::default()
6095 };
6096 assert_eq!(session_config(&cfg, Capabilities::empty()).max_frames_behind_gap, None);
6097 }
6098
6099 #[test]
6100 fn a_zero_gap_bound_should_disable_it_at_the_node_level_too() {
6101 // `0` means "not for me" wherever it is written. Read literally it would be the strictest
6102 // possible bound -- abandon the gap before a single frame arrives behind it -- so the two
6103 // levels would mean opposite things by the same value.
6104 let cfg = SessionManagerConfig {
6105 max_frames_behind_gap: Some(0),
6106 ..Default::default()
6107 };
6108 assert_eq!(session_config(&cfg, Capabilities::empty()).max_frames_behind_gap, None);
6109 }
6110
6111 /// Balancer state with the given target sitting at the given level.
6112 fn gate_state(target: u64, buffer_level: u64) -> BalancerStateValues {
6113 let state = BalancerStateValues::new(SurbBalancerConfig {
6114 target_surb_buffer_size: target,
6115 ..Default::default()
6116 });
6117 state
6118 .buffer_level
6119 .store(buffer_level, std::sync::atomic::Ordering::Relaxed);
6120 state
6121 }
6122
6123 /// A packet short enough to have room for a SURB. `max_surbs_with_message` is
6124 /// `(PAYLOAD_SIZE - len) / SURB_SIZE`, so anything past roughly half the payload already caps
6125 /// itself at zero and would make the assertions below pass for the wrong reason.
6126 fn small_outgoing_packet() -> anyhow::Result<ApplicationDataOut> {
6127 let data = ApplicationDataOut::with_no_packet_info(ApplicationData::new(SESSION_APPLICATION_TAG, b"short")?);
6128 anyhow::ensure!(
6129 data.estimate_surbs_with_msg() >= 1,
6130 "fixture must have room for a SURB, otherwise the cap is not what is being measured"
6131 );
6132 Ok(data)
6133 }
6134
6135 /// `account_incoming_surbs` must keep the estimator's held count (`saturating_diff`) equal to
6136 /// what a bounded store of capacity `CAP` would actually hold across a fill-past-capacity-then-drain
6137 /// sequence. The eviction term is what makes the held count fall back to zero once the overflow is
6138 /// drained -- and so what stops the keep-alive report and the PIX fill reserve from reading a level
6139 /// that never falls.
6140 #[test]
6141 fn account_incoming_surbs_tracks_a_bounded_store_through_overflow_and_drain() {
6142 const CAP: u64 = 100;
6143 let estimator = AtomicSurbFlowEstimator::default();
6144 let mut occupancy: u64 = 0;
6145
6146 let mut receive = |batch: u64| {
6147 let evicted = (occupancy + batch).saturating_sub(CAP);
6148 occupancy = (occupancy + batch).min(CAP);
6149 account_incoming_surbs(&estimator, batch, evicted);
6150 };
6151
6152 for _ in 0..5 {
6153 receive(40);
6154 }
6155 assert_eq!(occupancy, CAP, "the model store is full");
6156 assert_eq!(
6157 estimator.saturating_diff(),
6158 CAP,
6159 "held count tracks a full store, not the {}-SURB total received",
6160 5 * 40
6161 );
6162
6163 for _ in 0..CAP {
6164 estimator.consumed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6165 occupancy -= 1;
6166 }
6167 assert_eq!(occupancy, 0, "the model store is empty");
6168 assert_eq!(
6169 estimator.saturating_diff(),
6170 0,
6171 "held count returns to zero after the drain; the evicted surplus is not counted as held"
6172 );
6173 }
6174
6175 /// The gate primitive itself mints whenever the level is below the effective target. A level
6176 /// short of target with no capacity inferred yet still mints -- the stop for a smaller-than-target
6177 /// store comes from inferring its capacity over successive reports (see
6178 /// `a_plateau_below_target_infers_the_counterparty_capacity` in `balancer/controller.rs`), which
6179 /// lowers the effective target rather than changing this primitive.
6180 #[test]
6181 fn organic_minting_continues_below_target_without_an_inferred_capacity() -> anyhow::Result<()> {
6182 const EXIT_CAPACITY: u64 = 500;
6183 const ENTRY_TARGET: u64 = 2_000;
6184
6185 let mut data = small_outgoing_packet()?;
6186 cap_organic_surbs(&mut data, 1, &gate_state(ENTRY_TARGET, EXIT_CAPACITY));
6187
6188 assert_eq!(Some(1), data.packet_info.map(|i| i.max_surbs_in_packet));
6189 assert_eq!(
6190 1,
6191 data.estimate_surbs_with_msg(),
6192 "below the effective target with no inferred capacity, the Entry mints"
6193 );
6194 Ok(())
6195 }
6196
6197 #[test]
6198 fn cap_organic_surbs_should_zero_the_packet_once_the_counterparty_is_at_target() -> anyhow::Result<()> {
6199 let mut data = small_outgoing_packet()?;
6200 cap_organic_surbs(&mut data, 1, &gate_state(100, 200));
6201
6202 assert_eq!(Some(0), data.packet_info.map(|i| i.max_surbs_in_packet));
6203 assert_eq!(
6204 0,
6205 data.estimate_surbs_with_msg(),
6206 "the cap must reach the SURB accounting, not just the packet info"
6207 );
6208 Ok(())
6209 }
6210
6211 #[test]
6212 fn cap_organic_surbs_should_allow_one_while_the_counterparty_is_below_target() -> anyhow::Result<()> {
6213 let mut data = small_outgoing_packet()?;
6214 cap_organic_surbs(&mut data, 1, &gate_state(100, 10));
6215
6216 assert_eq!(Some(1), data.packet_info.map(|i| i.max_surbs_in_packet));
6217 assert_eq!(1, data.estimate_surbs_with_msg());
6218 Ok(())
6219 }
6220
6221 /// The cap writes into a field it may have to create, so it must not take the rest of the packet
6222 /// info with it — the routing stage sets the outgoing signals on this same struct.
6223 #[test]
6224 fn cap_organic_surbs_should_not_clobber_existing_packet_info() -> anyhow::Result<()> {
6225 let mut data = small_outgoing_packet()?;
6226 data.packet_info = Some(OutgoingPacketInfo {
6227 signals_to_destination: PacketSignal::SurbDistress.into(),
6228 max_surbs_in_packet: usize::MAX,
6229 surb_generation: Some(7),
6230 });
6231
6232 cap_organic_surbs(&mut data, 1, &gate_state(100, 200));
6233
6234 let info = data.packet_info.expect("packet info must survive");
6235 assert_eq!(0, info.max_surbs_in_packet);
6236 assert_eq!(
6237 PacketSignals::from(PacketSignal::SurbDistress),
6238 info.signals_to_destination
6239 );
6240 assert_eq!(
6241 Some(7),
6242 info.surb_generation,
6243 "the generation captured at path resolution must survive the cap"
6244 );
6245 Ok(())
6246 }
6247
6248 /// `usize::MAX` is an explicit statement by the client about its own traffic, so it
6249 /// wins over the balancer's estimate — and must leave the packet entirely untouched rather than
6250 /// writing some larger cap.
6251 #[test]
6252 fn maxing_out_surbs_should_bypass_the_balancer_gate() -> anyhow::Result<()> {
6253 let mut data = small_outgoing_packet()?;
6254 cap_organic_surbs(&mut data, usize::MAX, &gate_state(100, 200));
6255
6256 assert_eq!(None, data.packet_info, "the opt-in must not touch the packet at all");
6257 assert!(data.estimate_surbs_with_msg() >= 1);
6258 Ok(())
6259 }
6260
6261 #[test]
6262 fn cap_organic_surbs_should_honor_explicit_packet_limits() -> anyhow::Result<()> {
6263 for limit in [0, 2] {
6264 for buffer_level in [10, 200] {
6265 let mut data = small_outgoing_packet()?;
6266 cap_organic_surbs(&mut data, limit, &gate_state(100, buffer_level));
6267
6268 assert_eq!(Some(limit), data.packet_info.map(|info| info.max_surbs_in_packet));
6269 assert_eq!(limit, data.estimate_surbs_with_msg());
6270 }
6271 }
6272 Ok(())
6273 }
6274
6275 #[async_trait::async_trait]
6276 trait SendMsg {
6277 async fn send_message(
6278 &self,
6279 routing: DestinationRouting,
6280 data: ApplicationDataOut,
6281 ) -> crate::errors::Result<()>;
6282 }
6283
6284 mockall::mock! {
6285 MsgSender {}
6286 impl SendMsg for MsgSender {
6287 fn send_message<'a, 'b>(&'a self, routing: DestinationRouting, data: ApplicationDataOut)
6288 -> BoxFuture<'b, crate::errors::Result<()>> where 'a: 'b, Self: Sync + 'b;
6289 }
6290 }
6291
6292 fn mock_packet_planning(
6293 sender: MockMsgSender,
6294 ) -> (
6295 UnboundedSender<(DestinationRouting, ApplicationDataOut)>,
6296 tokio::task::JoinHandle<anyhow::Result<()>>,
6297 ) {
6298 let (tx, rx) = futures::channel::mpsc::unbounded();
6299 let handle = tokio::task::spawn(async move {
6300 pin_mut!(rx);
6301 while let Some((routing, data)) = rx.next().await {
6302 sender.send_message(routing, data).await?;
6303 }
6304 Ok(())
6305 });
6306 (tx, handle)
6307 }
6308
6309 fn msg_type(data: &ApplicationDataOut, expected: StartProtocolDiscriminants) -> bool {
6310 HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
6311 .map(|d| StartProtocolDiscriminants::from(d) == expected)
6312 .unwrap_or(false)
6313 }
6314
6315 fn start_msg_match(data: &ApplicationDataOut, msg: impl Fn(HoprStartProtocol) -> bool) -> bool {
6316 HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
6317 .map(msg)
6318 .unwrap_or(false)
6319 }
6320
6321 /// Waits (bounded) until the manager reports no active sessions.
6322 ///
6323 /// The session-slot rollback runs on a spawned task, so its effect is observed
6324 /// asynchronously; this polls [`SessionManager::active_sessions`] until it drains.
6325 async fn wait_for_no_active_sessions(
6326 mgr: &SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>,
6327 ) -> bool {
6328 for _ in 0..50 {
6329 if mgr.active_sessions().is_empty() {
6330 return true;
6331 }
6332 tokio::time::sleep(Duration::from_millis(20)).await;
6333 }
6334 mgr.active_sessions().is_empty()
6335 }
6336
6337 const SESSION_FORWARD_CAPACITY: usize = 10000;
6338
6339 /// Verifies that a session's SURB balancer config can be retrieved and updated via the manager API.
6340 ///
6341 /// ## Steps
6342 /// 1. A session slot is manually inserted into Alice's manager with a known `SurbBalancerConfig`
6343 /// (`target_surb_buffer_size: 1000`, `max_surbs_per_sec: 100`).
6344 /// 2. `get_surb_balancer_config` returns the config, confirming round-trip storage.
6345 /// 3. `update_surb_balancer_config` is called with a new config (`target: 2000`, `max: 200`).
6346 /// 4. `get_surb_balancer_config` is called again and the returned config matches the updated values.
6347 #[test_log::test(tokio::test)]
6348 async fn session_manager_should_update_surb_balancer_config() -> anyhow::Result<()> {
6349 let alice_pseudonym = HoprPseudonym::random();
6350 let session_id = alice_pseudonym;
6351 let balancer_cfg = SurbBalancerConfig {
6352 target_surb_buffer_size: 1000,
6353 max_surbs_per_sec: 100,
6354 ..Default::default()
6355 };
6356
6357 let alice_mgr =
6358 SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
6359
6360 let (dummy_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
6361 alice_mgr.sessions.insert(
6362 session_id,
6363 SessionSlot {
6364 session_tx: dummy_tx,
6365 routing_opts: DestinationRouting::Return(SurbMatcher::Pseudonym(alice_pseudonym)),
6366 abort_handles: Default::default(),
6367 surb_mgmt: Arc::new(BalancerStateValues::from(balancer_cfg)),
6368 surb_estimator: Default::default(),
6369 current_ssa_state: Default::default(),
6370 pix_supervisor: Default::default(),
6371 pix_egress_gate: Default::default(),
6372 pix_fill: Default::default(),
6373 returned_packets: Default::default(),
6374 cycle_budget: None,
6375 },
6376 );
6377
6378 let actual_cfg = alice_mgr
6379 .get_surb_balancer_config(&session_id)?
6380 .ok_or(anyhow!("session must have a surb balancer config"))?;
6381 assert_eq!(actual_cfg, balancer_cfg);
6382
6383 let new_cfg = SurbBalancerConfig {
6384 target_surb_buffer_size: 2000,
6385 max_surbs_per_sec: 200,
6386 ..Default::default()
6387 };
6388 alice_mgr.update_surb_balancer_config(&session_id, new_cfg)?;
6389
6390 let actual_cfg = alice_mgr
6391 .get_surb_balancer_config(&session_id)?
6392 .ok_or(anyhow!("session must have a surb balancer config"))?;
6393 assert_eq!(actual_cfg, new_cfg);
6394
6395 Ok(())
6396 }
6397
6398 /// Verifies that a self-initiated session is rejected with `SessionManagerError::Loopback`.
6399 ///
6400 /// ## Steps
6401 /// 1. Alice's manager is started with a mock transport that delivers messages back to itself.
6402 /// 2. Alice initiates a session toward `bob_peer`; the mock routes her `StartSession` back to her own manager
6403 /// (simulating a network loopback).
6404 /// 3. Alice's manager processes `StartSession` as incoming, auto-responds with `SessionEstablished`, and the mock
6405 /// delivers it back to complete the handshake.
6406 /// 4. `new_session` returns `Err(TransportSessionError::Manager(SessionManagerError::Loopback))`.
6407 /// 5. Exactly one active session is present — the incoming slot accepted from the self-delivered `StartSession`.
6408 /// The rejection fires after slot insertion, not before.
6409 #[test_log::test(tokio::test)]
6410 async fn session_manager_should_not_allow_loopback_sessions() -> anyhow::Result<()> {
6411 let alice_pseudonym = HoprPseudonym::random();
6412 let bob_peer: Address = (&ChainKeypair::random()).into();
6413
6414 let alice_mgr = SessionManager::new(Default::default());
6415
6416 let mut sequence = mockall::Sequence::new();
6417 let mut alice_transport = MockMsgSender::new();
6418
6419 // Alice sends the StartSession message
6420 let alice_mgr_clone = alice_mgr.clone();
6421 alice_transport
6422 .expect_send_message()
6423 .once()
6424 .in_sequence(&mut sequence)
6425 .withf(move |peer, data| {
6426 msg_type(data, StartProtocolDiscriminants::StartSession)
6427 && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
6428 })
6429 .returning(move |_, data| {
6430 // But the message is again processed by Alice due to Loopback
6431 let alice_mgr_clone = alice_mgr_clone.clone();
6432 Box::pin(async move {
6433 alice_mgr_clone
6434 .dispatch_message(
6435 alice_pseudonym,
6436 ApplicationDataIn {
6437 data: data.data,
6438 packet_info: Default::default(),
6439 },
6440 )
6441 ?;
6442 Ok(())
6443 })
6444 });
6445
6446 // Alice sends the SessionEstablished message (as Bob)
6447 let alice_mgr_clone = alice_mgr.clone();
6448 alice_transport
6449 .expect_send_message()
6450 .once()
6451 .in_sequence(&mut sequence)
6452 .withf(move |peer, data| {
6453 msg_type(data, StartProtocolDiscriminants::SessionEstablished)
6454 && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if *p == alice_pseudonym)
6455 })
6456 .returning(move |_, data| {
6457 let alice_mgr_clone = alice_mgr_clone.clone();
6458
6459 Box::pin(async move {
6460 alice_mgr_clone.dispatch_message(
6461 alice_pseudonym,
6462 ApplicationDataIn {
6463 data: data.data,
6464 packet_info: Default::default(),
6465 },
6466 )?;
6467 Ok(())
6468 })
6469 });
6470
6471 // Start Alice
6472 let (new_session_tx_alice, new_session_rx_alice) = futures::channel::mpsc::channel(1024);
6473 let (alice_sender, alice_handle) = mock_packet_planning(alice_transport);
6474 alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?;
6475 assert!(alice_mgr.is_started());
6476
6477 let alice_session = alice_mgr
6478 .new_session(
6479 bob_peer,
6480 SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6481 SessionClientConfig {
6482 capabilities: None.into(),
6483 pseudonym: alice_pseudonym.into(),
6484 surb_management: None,
6485 ..Default::default()
6486 },
6487 )
6488 .await;
6489
6490 println!("{alice_session:?}");
6491 assert!(matches!(
6492 alice_session,
6493 Err(TransportSessionError::Manager(SessionManagerError::Loopback))
6494 ));
6495 // There is one session in the manager, which is the incoming one that Alice's manager
6496 // accepted when it received the StartSession message from itself.
6497 assert_eq!(alice_mgr.num_active_sessions(), 1);
6498
6499 drop(new_session_rx_alice);
6500
6501 // Cleanup: close sender and await handle
6502 alice_sender.close_channel();
6503 alice_handle.await??;
6504
6505 Ok(())
6506 }
6507
6508 /// Verifies that a session initiation returns `TransportSessionError::Timeout` when the peer
6509 /// never processes or responds to the `StartSession` message.
6510 ///
6511 /// ## Steps
6512 /// 1. Alice's manager is configured with `initiation_timeout_base: 100ms`. Bob's manager is started but its mock
6513 /// transport silently swallows all messages (never dispatches to the manager).
6514 /// 2. Alice calls `new_session`; her `StartSession` is captured by the mock and silently discarded.
6515 /// 3. The 100ms timeout expires; `new_session` returns `Err(TransportSessionError::Timeout)`.
6516 /// 4. `num_active_sessions` is 0, confirming no orphaned slot was left in the cache.
6517 #[test_log::test(tokio::test)]
6518 async fn session_manager_should_timeout_new_session_attempt_when_no_response() -> anyhow::Result<()> {
6519 let bob_peer: Address = (&ChainKeypair::random()).into();
6520
6521 let cfg = SessionManagerConfig {
6522 initiation_timeout_base: Duration::from_millis(100),
6523 ..Default::default()
6524 };
6525
6526 let alice_mgr = SessionManager::new(cfg);
6527 let bob_mgr = SessionManager::new(Default::default());
6528
6529 let mut sequence = mockall::Sequence::new();
6530 let mut alice_transport = MockMsgSender::new();
6531 let bob_transport = MockMsgSender::new();
6532
6533 // Alice sends the StartSession message, but Bob does not handle it
6534 alice_transport
6535 .expect_send_message()
6536 .once()
6537 .in_sequence(&mut sequence)
6538 .withf(move |peer, data| {
6539 msg_type(data, StartProtocolDiscriminants::StartSession)
6540 && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
6541 })
6542 .returning(|_, _| Box::pin(async { Ok(()) }));
6543
6544 // Start Alice
6545 let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
6546 let (alice_sender, _alice_handle) = mock_packet_planning(alice_transport);
6547 alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?;
6548 assert!(alice_mgr.is_started());
6549
6550 // Start Bob
6551 let (new_session_tx_bob, _) = futures::channel::mpsc::channel(1024);
6552 let (bob_sender, _bob_handle) = mock_packet_planning(bob_transport);
6553 bob_mgr.start(bob_sender.clone(), new_session_tx_bob, None, None)?;
6554 assert!(bob_mgr.is_started());
6555
6556 let result = alice_mgr
6557 .new_session(
6558 bob_peer,
6559 SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6560 SessionClientConfig {
6561 capabilities: None.into(),
6562 pseudonym: None,
6563 surb_management: None,
6564 ..Default::default()
6565 },
6566 )
6567 .await;
6568
6569 assert!(matches!(result, Err(TransportSessionError::Timeout)));
6570 assert_eq!(alice_mgr.num_active_sessions(), 0);
6571
6572 Ok(())
6573 }
6574
6575 /// Verifies that a failed incoming session establishment does not register any telemetry.
6576 ///
6577 /// ## Steps
6578 /// 1. A `SessionManager` is started with the `telemetry` feature enabled.
6579 /// 2. The new-session notification channel's receiver is dropped immediately, so notifying about a new incoming
6580 /// session will fail.
6581 /// 3. `handle_incoming_session_initiation` is called with a random pseudonym. The slot is inserted first, then
6582 /// notifying about the new session fails (receiver is gone).
6583 /// 4. `wait_for_no_active_sessions` polls until there are no active sessions, confirming the partially-inserted
6584 /// slot was rolled back.
6585 /// 5. `num_active_sessions` is 0, proving the rollback prevented any telemetry registration for the failed session.
6586 #[cfg(feature = "telemetry")]
6587 #[test_log::test(tokio::test)]
6588 async fn failed_incoming_session_establishment_does_not_register_telemetry() -> anyhow::Result<()> {
6589 let mgr = SessionManager::new(Default::default());
6590
6591 let transport = MockMsgSender::new();
6592 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
6593 drop(new_session_rx);
6594 let (sender, _handle) = mock_packet_planning(transport);
6595 mgr.start(sender.clone(), new_session_tx, None, None)?;
6596 assert!(mgr.is_started());
6597
6598 let pseudonym = HoprPseudonym::random();
6599 let result = mgr
6600 .handle_incoming_session_initiation(
6601 pseudonym,
6602 StartInitiation {
6603 challenge: MIN_CHALLENGE,
6604 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6605 capabilities: HoprSessionCapabilities(Capabilities::empty()),
6606 additional_data: 0,
6607 },
6608 )
6609 .await;
6610
6611 assert!(result.is_err());
6612
6613 // The slot inserted before the failure must be rolled back, so it neither
6614 // counts towards `maximum_sessions` nor registers any telemetry.
6615 assert!(
6616 wait_for_no_active_sessions(&mgr).await,
6617 "the partially established session slot was not rolled back"
6618 );
6619 assert_eq!(mgr.num_active_sessions(), 0);
6620
6621 // Cleanup: close sender and await handle
6622 sender.close_channel();
6623 _handle.await??;
6624
6625 Ok(())
6626 }
6627
6628 /// Verifies that a session slot is rolled back if session setup fails after the slot is inserted.
6629 ///
6630 /// ## Steps
6631 /// 1. A `SessionManager` is started; the new-session notification channel's receiver is dropped, so notifying about
6632 /// a new incoming session will fail.
6633 /// 2. `handle_incoming_session_initiation` is called with a random pseudonym. The slot is inserted into the cache
6634 /// first, then notifying about the new session fails (because the receiver is gone).
6635 /// 3. The call returns an error, and `wait_for_no_active_sessions` confirms the slot was removed.
6636 /// 4. `num_active_sessions` is 0, proving the rollback removed the slot and freed the pseudonym.
6637 #[test_log::test(tokio::test)]
6638 async fn session_manager_should_roll_back_slot_when_incoming_session_setup_fails() -> anyhow::Result<()> {
6639 let mgr = SessionManager::new(Default::default());
6640
6641 // Drop the receiver so that notifying about the new incoming session fails
6642 // *after* the session slot has already been inserted into the cache.
6643 let transport = MockMsgSender::new();
6644 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
6645 drop(new_session_rx);
6646 let (sender, handle) = mock_packet_planning(transport);
6647 mgr.start(sender.clone(), new_session_tx, None, None)?;
6648 assert!(mgr.is_started());
6649
6650 let pseudonym = HoprPseudonym::random();
6651
6652 // The setup fails after the slot is inserted (notifying about the new
6653 // incoming session errors out because the receiver is gone), so the slot
6654 // must be rolled back instead of lingering until idle eviction.
6655 let result = mgr
6656 .handle_incoming_session_initiation(
6657 pseudonym,
6658 StartInitiation {
6659 challenge: MIN_CHALLENGE,
6660 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6661 capabilities: HoprSessionCapabilities::empty(),
6662 additional_data: 0,
6663 },
6664 )
6665 .await;
6666 assert!(result.is_err());
6667
6668 // An empty active-session set proves the slot was removed and, since
6669 // sessions are keyed by pseudonym, that the pseudonym is free again.
6670 assert!(
6671 wait_for_no_active_sessions(&mgr).await,
6672 "the partially established session slot was not rolled back"
6673 );
6674
6675 // Cleanup
6676 sender.close_channel();
6677 handle.await??;
6678
6679 Ok(())
6680 }
6681
6682 /// Collects everything that arrives on `rx` during `window`, returning once it elapses.
6683 async fn originated_during(
6684 rx: &mut futures::channel::mpsc::UnboundedReceiver<(DestinationRouting, ApplicationDataOut)>,
6685 window: Duration,
6686 ) -> Vec<(DestinationRouting, ApplicationDataOut)> {
6687 let mut collected = Vec::new();
6688 let deadline = tokio::time::Instant::now() + window;
6689 while let Ok(Some(item)) = timeout(
6690 deadline.saturating_duration_since(tokio::time::Instant::now()),
6691 rx.next(),
6692 )
6693 .await
6694 {
6695 collected.push(item);
6696 }
6697 collected
6698 }
6699
6700 /// The manager floors the keep-alive period at [`MIN_SURB_BUFFER_NOTIFICATION_PERIOD`], so this
6701 /// is as fast as an Exit keep-alive can be made to run, and it sets the pace of these tests.
6702 const KEEP_ALIVE_PERIOD: Duration = MIN_SURB_BUFFER_NOTIFICATION_PERIOD;
6703
6704 type RecordingManager = SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>;
6705 type Originated = futures::channel::mpsc::UnboundedReceiver<(DestinationRouting, ApplicationDataOut)>;
6706
6707 /// Brings up an Exit-side session and returns once its keep-alive stream is observably running.
6708 ///
6709 /// The "observably running" part is load-bearing for every caller: `nothing was originated` is
6710 /// equally true of a stream that stopped and one that never started, so a test that does not
6711 /// first establish the stream is alive proves nothing when it later sees silence.
6712 async fn exit_session_originating_keep_alives(
6713 cfg: SessionManagerConfig,
6714 ) -> anyhow::Result<(RecordingManager, Originated, HoprPseudonym)> {
6715 let mgr = RecordingManager::new(SessionManagerConfig {
6716 surb_balance_notify_period: Some(KEEP_ALIVE_PERIOD),
6717 ..cfg
6718 });
6719
6720 let (msg_tx, mut msg_rx) = futures::channel::mpsc::unbounded();
6721 // Held, not dropped: dropping it would fail the establishment notification instead.
6722 let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(4);
6723 mgr.start(msg_tx, new_session_tx, None, None)?;
6724
6725 let pseudonym = HoprPseudonym::random();
6726 mgr.handle_incoming_session_initiation(
6727 pseudonym,
6728 StartInitiation {
6729 challenge: MIN_CHALLENGE,
6730 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
6731 // Empty capabilities keep rate control on, which is what spawns the balancer and
6732 // the keep-alive stream. `NoRateControl` would skip both and make this vacuous.
6733 capabilities: HoprSessionCapabilities(Capabilities::empty()),
6734 additional_data: 0,
6735 },
6736 )
6737 .await?;
6738
6739 let observed = originated_during(&mut msg_rx, KEEP_ALIVE_PERIOD * 2 + Duration::from_millis(500)).await;
6740 let keep_alives = observed
6741 .iter()
6742 .filter(|(routing, data)| {
6743 msg_type(data, StartProtocolDiscriminants::KeepAlive)
6744 && matches!(routing, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if p == &pseudonym)
6745 })
6746 .count();
6747 anyhow::ensure!(
6748 keep_alives > 0,
6749 "no return-routed keep-alive was originated, so this test cannot tell a stopped stream from one that \
6750 never ran; {} message(s) were observed in total",
6751 observed.len()
6752 );
6753
6754 Ok((mgr, msg_rx, pseudonym))
6755 }
6756
6757 /// Asserts `mgr` originates nothing further for `pseudonym`.
6758 ///
6759 /// Packets already handed to the sender before the closure are in flight rather than newly
6760 /// originated, so they are drained first and only what appears afterwards counts.
6761 async fn assert_no_further_origination(rx: &mut Originated, closure: &str) {
6762 let _in_flight = originated_during(rx, Duration::from_millis(200)).await;
6763
6764 let window = KEEP_ALIVE_PERIOD * 3;
6765 let after = originated_during(rx, window).await;
6766 assert!(
6767 after.is_empty(),
6768 "an Exit session closed by {closure} originated {} further packet(s) over {window:?} — each one is \
6769 return-routed to a pseudonym whose SURBs are gone, and one such packet is enough to stall all \
6770 origination on the node",
6771 after.len()
6772 );
6773 }
6774
6775 /// An Exit session must originate nothing once it has been closed explicitly.
6776 ///
6777 /// The Exit's keep-alive stream is a `repeat_with` on a rate limiter: it produces a
6778 /// return-routed packet every period regardless of whether a SURB exists to carry it. That is
6779 /// fine while the initiator is present and replenishing, and it is the *supply* side of the
6780 /// `london-01` outage once the initiator is gone — every such packet is one the routing
6781 /// resolution stage can never resolve, and one unresolvable packet there stalls all origination
6782 /// on the node (see `hopr_transport::path::resolve`).
6783 ///
6784 /// Teardown is therefore the only thing standing between a departed initiator and an unbounded
6785 /// supply of unresolvable packets, which is why each closure path has to be shown to stop the
6786 /// stream rather than merely drop the slot.
6787 #[test_log::test(tokio::test)]
6788 async fn an_exit_session_should_originate_nothing_after_an_explicit_close() -> anyhow::Result<()> {
6789 // Default idle timeout (180 s), so eviction cannot confound what the explicit close proves.
6790 let (mgr, mut msg_rx, pseudonym) = exit_session_originating_keep_alives(Default::default()).await?;
6791
6792 assert!(mgr.close_session(&pseudonym), "the session must exist to be closed");
6793
6794 assert_no_further_origination(&mut msg_rx, "an explicit close").await;
6795 Ok(())
6796 }
6797
6798 /// An Exit session must originate nothing once it has been evicted for being idle.
6799 ///
6800 /// This is the path that matters most for a departed initiator: nobody closes that session, so
6801 /// idle eviction is what ends it, and eviction runs through a Moka listener rather than the
6802 /// explicit close path. A keep-alive stream that survives eviction would go on originating
6803 /// unresolvable return packets with no session left to account for them.
6804 #[test_log::test(tokio::test)]
6805 async fn an_exit_session_should_originate_nothing_after_idle_eviction() -> anyhow::Result<()> {
6806 let idle_timeout = KEEP_ALIVE_PERIOD * 3;
6807 let (mgr, mut msg_rx, _) = exit_session_originating_keep_alives(SessionManagerConfig {
6808 idle_timeout,
6809 ..Default::default()
6810 })
6811 .await?;
6812
6813 // Moka evicts lazily, so drive its maintenance rather than waiting for the manager's own
6814 // (jittered, multi-second) eviction tick: this makes the eviction prompt and deterministic.
6815 for _ in 0..50 {
6816 mgr.sessions.run_pending_tasks();
6817 if mgr.active_sessions().is_empty() {
6818 break;
6819 }
6820 tokio::time::sleep(idle_timeout / 10).await;
6821 }
6822 assert!(
6823 mgr.active_sessions().is_empty(),
6824 "the idle session was never evicted, so this test cannot say anything about eviction"
6825 );
6826
6827 assert_no_further_origination(&mut msg_rx, "idle eviction").await;
6828 Ok(())
6829 }
6830
6831 // ---------------------------------------------------------------
6832 // PIX fill — the Exit's own keep-alives
6833 // ---------------------------------------------------------------
6834
6835 /// A [`PixFillControl`] over a random pseudonym and an empty SURB estimate.
6836 ///
6837 /// `reserve` is the *effective* reserve, as [`effective_surb_reserve`] would have derived it —
6838 /// these tests are about what `admit` does with one, not about where it came from. Against an
6839 /// estimate that starts empty, any `reserve` above zero refuses fill outright, which is what makes
6840 /// admission a usable assertion that a packet was classified as the notification.
6841 fn fill_control(notify_period: Option<Duration>, reserve: u64) -> PixFillControl {
6842 PixFillControl::new(
6843 HoprPseudonym::random(),
6844 RateController::new(0, Duration::from_secs(1)),
6845 notify_period,
6846 reserve,
6847 Default::default(),
6848 )
6849 }
6850
6851 /// The `surb_starved` gauge moves on the edges, and a control dropped mid-stall returns it.
6852 ///
6853 /// Per refused packet would measure how hard the stream retried rather than how long the
6854 /// Session was starved, and a control that went away while withholding would leave the count
6855 /// standing for the life of the process.
6856 #[test]
6857 fn the_surb_starved_gauge_counts_stalls_and_is_returned_on_drop() {
6858 use crate::supervision::telemetry::probe;
6859
6860 probe::reset();
6861 let control = fill_control(None, 10);
6862 let now = Instant::now();
6863
6864 // The estimator starts empty, so every one of these is below the reserve of ten.
6865 for _ in 0..3 {
6866 assert!(!control.admit_at(now), "fill must be withheld below the reserve");
6867 }
6868 assert_eq!(
6869 1,
6870 probe::get("sessions_stalled/surb_starved"),
6871 "three refusals are one stall, not three"
6872 );
6873
6874 // Enough SURBs arrive to clear the reserve.
6875 control
6876 .estimator
6877 .produced
6878 .fetch_add(10, std::sync::atomic::Ordering::Relaxed);
6879 assert!(control.admit_at(now));
6880 assert_eq!(0, probe::get("sessions_stalled/surb_starved"));
6881
6882 // And a control dropped while withholding returns its own.
6883 control
6884 .estimator
6885 .consumed
6886 .fetch_add(10, std::sync::atomic::Ordering::Relaxed);
6887 assert!(!control.admit_at(now));
6888 assert_eq!(1, probe::get("sessions_stalled/surb_starved"));
6889 drop(control);
6890 assert_eq!(
6891 0,
6892 probe::get("sessions_stalled/surb_starved"),
6893 "a session that dies mid-stall must not leave the gauge standing"
6894 );
6895 }
6896
6897 /// The fill reserve reads the estimator's held count, which must not stay inflated by evicted
6898 /// SURBs. After an overflow drains, the real store is empty, yet a raw `produced - consumed` would
6899 /// still read far above the reserve and admit fill into an empty buffer -- the exact failure the
6900 /// reserve exists to prevent (a return packet that finds no SURB stalls every packet the node
6901 /// originates). With evictions counted, the held count is what the store holds, so fill is withheld.
6902 #[test]
6903 fn the_fill_reserve_is_not_defeated_by_a_post_overflow_estimate() {
6904 use std::sync::atomic::Ordering;
6905
6906 const RESERVE: u64 = 100;
6907 let control = fill_control(None, RESERVE);
6908 let now = Instant::now();
6909
6910 // Post-overflow-drain: 1000 SURBs arrived, the full store evicted 900 of them, and the 100 it
6911 // actually held were all spent. The store is empty; `produced - consumed` alone reads 900.
6912 control.estimator.produced.fetch_add(1000, Ordering::Relaxed);
6913 control.estimator.evicted.fetch_add(900, Ordering::Relaxed);
6914 control.estimator.consumed.fetch_add(100, Ordering::Relaxed);
6915
6916 assert_eq!(0, control.drainable_surbs(), "an empty store has nothing to drain");
6917 assert!(
6918 !control.admit_at(now),
6919 "fill must be withheld: the real held count is below the reserve"
6920 );
6921 }
6922
6923 /// One stream, two producers, and the faster of them wins.
6924 ///
6925 /// The SURB-level notification and PIX fill are the same packet sent for different reasons, so
6926 /// running two streams of them would double the Exit's return traffic to say the same thing
6927 /// twice. What has to be preserved is the floor: turning fill off must leave the notification
6928 /// running, and a Session with no notification configured must still be fillable — that second
6929 /// case is the deployment's own test fixtures, which pin `surb_balance_notify_period: None`.
6930 #[test]
6931 fn the_exit_keep_alive_rate_is_the_faster_of_its_two_producers() {
6932 let notify = Duration::from_secs(60);
6933 let control = fill_control(Some(notify), 1);
6934
6935 assert_eq!(
6936 FillRate::ZERO,
6937 control.effective_rate(),
6938 "nothing runs before the notification's initial delay has elapsed"
6939 );
6940
6941 control.start_notify();
6942 assert_eq!(FillRate::once_per(notify), control.effective_rate());
6943
6944 control.set_fill(FillRate::per_second(10));
6945 assert_eq!(
6946 FillRate::per_second(10),
6947 control.effective_rate(),
6948 "fill above the notification rate must take over"
6949 );
6950
6951 control.set_fill(FillRate::once_per(Duration::from_secs(600)));
6952 assert_eq!(
6953 FillRate::once_per(notify),
6954 control.effective_rate(),
6955 "fill below the notification rate must not slow it down"
6956 );
6957
6958 control.set_fill(FillRate::ZERO);
6959 assert_eq!(
6960 FillRate::once_per(notify),
6961 control.effective_rate(),
6962 "a Session that has stopped filling still reports its SURB level"
6963 );
6964
6965 // And the other way round: no notification configured, so fill is the only producer.
6966 let fill_only = fill_control(None, 1);
6967 fill_only.start_notify();
6968 assert_eq!(FillRate::ZERO, fill_only.effective_rate());
6969 fill_only.set_fill(FillRate::per_second(4));
6970 assert_eq!(FillRate::per_second(4), fill_only.effective_rate());
6971 fill_only.set_fill(FillRate::ZERO);
6972 assert_eq!(FillRate::ZERO, fill_only.effective_rate());
6973 }
6974
6975 /// Fill yields to the Session's own SURB reserve; the SURB-level notification does not.
6976 ///
6977 /// The asymmetry is the whole rule. A return packet that finds no SURB is not dropped — the
6978 /// routing resolver holds it, and every packet this node originates behind it, for the whole of
6979 /// `surb_resolution_wait`. So an Exit that filled a cycle down to its last SURB would stall the
6980 /// Session it was filling for, and every other Session on the node with it. The notification is
6981 /// exempt because it is the message that *asks* for more SURBs: refusing to send it when SURBs
6982 /// are scarce is refusing to ask for the thing that is scarce.
6983 #[test]
6984 fn fill_withholds_below_the_surb_reserve_while_the_level_notification_does_not() {
6985 const RESERVE: u64 = 100;
6986 let control = fill_control(Some(Duration::from_secs(3600)), RESERVE);
6987 control.start_notify();
6988
6989 // The first packet is the notification, whatever the estimate says.
6990 assert!(control.admit(), "the level notification must go out on an empty buffer");
6991 // The notification period is an hour, so everything after it is fill — and there is nothing
6992 // to spend.
6993 assert!(!control.admit(), "fill must not spend a SURB the Session does not have");
6994
6995 control
6996 .estimator
6997 .produced
6998 .fetch_add(RESERVE - 1, std::sync::atomic::Ordering::Relaxed);
6999 assert!(!control.admit(), "one short of the reserve is still short");
7000
7001 control
7002 .estimator
7003 .produced
7004 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7005 assert!(control.admit(), "at the reserve, fill may spend");
7006
7007 // Consumption counted elsewhere walks the estimate back below the reserve.
7008 control
7009 .estimator
7010 .consumed
7011 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7012 assert!(!control.admit());
7013 }
7014
7015 /// Exactly one caller may take a Session into its drain, and doing so silences the notification.
7016 ///
7017 /// `begin_surb_drain` is reached from the operator's close, the closure notifier and
7018 /// `handle_session_error` independently, so two closes can both read [`is_draining`] as false and
7019 /// race for the same drain. The transition is what settles it: the winner is the one caller that
7020 /// tells the supervisor the Session closed, and the loser is answered as though it had arrived
7021 /// afterwards. Were the check and the write separate, both would start a drain and the supervisor
7022 /// would be told twice about one close.
7023 ///
7024 /// [`is_draining`]: PixFillControl::is_draining
7025 #[test]
7026 fn only_one_caller_can_take_a_session_into_its_drain() {
7027 let notify = Duration::from_secs(60);
7028 let control = fill_control(Some(notify), 1);
7029 control.start_notify();
7030 assert_eq!(FillRate::once_per(notify), control.effective_rate());
7031
7032 assert!(control.enter_drain(), "the first caller takes the drain");
7033 assert!(control.is_draining());
7034 assert!(
7035 !control.enter_drain(),
7036 "a concurrent close must lose the transition rather than start a second drain"
7037 );
7038 assert!(control.is_draining(), "losing the transition must not clear the drain");
7039
7040 assert_eq!(
7041 FillRate::ZERO,
7042 control.effective_rate(),
7043 "a draining stream runs at the fill rate alone, and nothing is filling it"
7044 );
7045 assert!(
7046 !control.admit(),
7047 "the notification is the one packet exempt from the reserve, and a drain silences it"
7048 );
7049
7050 // Nor can the one-shot delay task, which holds a clone of this control, turn it back on.
7051 control.start_notify();
7052 assert_eq!(FillRate::ZERO, control.effective_rate());
7053 }
7054
7055 /// The SURB-level notification is classified by when it was *due*, not by when the scheduler got
7056 /// round to releasing it.
7057 ///
7058 /// The rate limiter that releases these packets does not hit the period exactly: it sleeps in
7059 /// chunks of `MAX_WAIT_CHUNK`, re-reads its controller on each wake, and rounds a rate into an
7060 /// integer packet count. Compared against the period exactly, a period the controller rounds
7061 /// short by a microsecond would classify every other notification as fill — and, below the
7062 /// reserve, drop it. That is the message that *asks* for the SURBs whose absence caused the drop,
7063 /// and it runs on every Exit Session with a notification period configured, PIX or not.
7064 ///
7065 /// The reserve here is the instrument: the estimator is empty, so a packet the rule classifies as
7066 /// fill is refused and a packet it classifies as the notification is admitted. Admission is
7067 /// therefore the assertion that it was not counted as fill.
7068 #[test]
7069 fn a_level_notification_released_a_shade_early_is_still_the_notification() {
7070 const RESERVE: u64 = 100;
7071 // Two seconds, so the slack is `MAX_WAIT_CHUNK` rather than half the period, and the two
7072 // bounds are distinguishable.
7073 let period = Duration::from_secs(2);
7074 let control = fill_control(Some(period), RESERVE);
7075 control.start_notify();
7076
7077 let t0 = Instant::now();
7078 assert!(
7079 control.admit_at(t0),
7080 "the first packet is the notification, whatever the estimate says"
7081 );
7082
7083 // A shade under the period — the shape a rate rounded down by a microsecond produces.
7084 let early = t0 + period - Duration::from_micros(1);
7085 assert!(
7086 control.admit_at(early),
7087 "a notification released a microsecond early must not be reclassified as fill and dropped"
7088 );
7089
7090 // And it is the notification rather than a free pass: the next packet is measured from when
7091 // this one actually went out, so everything inside the following period is fill.
7092 assert!(
7093 !control.admit_at(early + Duration::from_millis(1)),
7094 "the packet after the notification is fill, and there is nothing to spend"
7095 );
7096 assert!(
7097 !control.admit_at(early + period - MAX_WAIT_CHUNK - Duration::from_millis(1)),
7098 "a packet inside the slack window is still fill"
7099 );
7100
7101 // The slack window opens `MAX_WAIT_CHUNK` before the period is up, and not earlier.
7102 assert!(
7103 control.admit_at(early + period - MAX_WAIT_CHUNK),
7104 "at the edge of the slack window the packet is the notification again"
7105 );
7106
7107 // A period short enough that `MAX_WAIT_CHUNK` would swallow it keeps half of itself.
7108 let short = Duration::from_millis(200);
7109 let control = fill_control(Some(short), RESERVE);
7110 control.start_notify();
7111 let t0 = Instant::now();
7112 assert!(control.admit_at(t0));
7113 assert!(
7114 !control.admit_at(t0 + short / 2 - Duration::from_millis(1)),
7115 "the slack is capped at half the period, so it cannot widen a short period away to nothing"
7116 );
7117 assert!(control.admit_at(t0 + short / 2));
7118 }
7119
7120 /// The reserve is a ceiling on a figure derived from the buffer the *Entry* asked for.
7121 ///
7122 /// The configured value is an absolute SURB count, while the buffer it is compared against is
7123 /// sized by the peer. Left absolute, a PIX Session whose Entry targets fewer SURBs than the
7124 /// reserve could never be filled at all — every fill packet withheld, the funded cycle stranded on
7125 /// `max_recovery_time`, and nothing in the logs but a backoff.
7126 #[test]
7127 fn the_fill_surb_reserve_is_capped_at_a_quarter_of_the_entrys_announced_buffer() {
7128 // The shipped reserve against the worked profile's buffer: the configured value binds and the
7129 // derivation is inert, which is what keeps this from quietly weakening production.
7130 assert_eq!(500, effective_surb_reserve(500, 7_000));
7131 // Exactly four times the reserve is the point at which the two agree.
7132 assert_eq!(500, effective_surb_reserve(500, 2_000));
7133
7134 // Below that, the Entry's buffer is what decides. The cluster fixtures' 64-SURB target is the
7135 // case this exists for.
7136 assert_eq!(16, effective_surb_reserve(500, 64));
7137 assert_eq!(12, effective_surb_reserve(500, 50));
7138
7139 // Never zero: an Entry announcing a buffer of three would otherwise let fill spend the
7140 // Session's last SURB, which is the one failure the reserve exists to prevent.
7141 assert_eq!(1, effective_surb_reserve(500, 3));
7142 assert_eq!(1, effective_surb_reserve(500, 0));
7143 assert_eq!(1, effective_surb_reserve(0, 7_000));
7144 }
7145
7146 /// Test dimensions whose cycle is large enough to need a visible fill rate.
7147 ///
7148 /// [`small_pix_params`] is six packets, which any deadline covers at the heartbeat; these are
7149 /// three hundred, which against the fixture's 40 s recovery budget needs about eleven packets a
7150 /// second — far enough above the one-per-second notification for the two to be told apart.
7151 fn fill_pix_params() -> PixParams {
7152 PixParams::try_new(100, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE).expect("test dimensions must be valid")
7153 }
7154
7155 /// Brings up an Exit-side PIX Session, funds its first cycle, and returns once it is recovering.
7156 ///
7157 /// The Entry half is not simulated: the supervisor is a state machine, and driving it through
7158 /// `SsaRequestSent`, `CommitmentVerified` and `DepositConfirmed` puts its cycle in exactly the
7159 /// state a real commitment and deposit would — which is the state fill is planned from. What is
7160 /// real here is everything downstream of that: the action driver, the rate controller, the
7161 /// admission predicate and the stream.
7162 async fn recovering_exit_pix_session(
7163 capabilities: Capabilities,
7164 notify_period: Option<Duration>,
7165 fill: crate::supervision::PixFillConfig,
7166 ) -> anyhow::Result<(RecordingManager, Originated, HoprPseudonym)> {
7167 let params = fill_pix_params();
7168 let mgr = RecordingManager::new(SessionManagerConfig {
7169 surb_balance_notify_period: notify_period,
7170 pix_config: IncomingSessionPixConfig {
7171 quota_range: 0..=pix_params_to_quota(¶ms),
7172 supervision: SupervisorConfig {
7173 // As short as a legal pair gets: the idle deadline must clear the reconstructor's
7174 // 30 s acknowledgement window, and the hard deadline must exceed the idle one.
7175 // That is what makes the required fill rate — a whole cycle over 75 % of 40 s,
7176 // about eleven packets a second — observable inside a test-length window.
7177 max_recovery_idle: Duration::from_secs(31),
7178 max_recovery_time: Duration::from_secs(40),
7179 fill,
7180 ..Default::default()
7181 },
7182 ..Default::default()
7183 },
7184 ..Default::default()
7185 });
7186
7187 let (msg_tx, msg_rx) = futures::channel::mpsc::unbounded();
7188 let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(4);
7189 mgr.start(
7190 msg_tx,
7191 new_session_tx,
7192 Some(pix_toolbox_with_pool(
7193 Arc::new(SsaShareGenerator::new(SsaGeneratorConfig {
7194 polynomials_per_ssa: params.polys_per_ssa(),
7195 threshold: params.shares_per_poly(),
7196 surplus_shares: params.surplus_shares(),
7197 })),
7198 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
7199 )),
7200 None,
7201 )?;
7202
7203 let pseudonym = HoprPseudonym::random();
7204 mgr.handle_incoming_session_initiation(
7205 pseudonym,
7206 StartInitiation {
7207 challenge: MIN_CHALLENGE,
7208 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
7209 capabilities: HoprSessionCapabilities(capabilities | Capability::UsePIX),
7210 additional_data: into_initiation_additional_data(params.into_additional_data(0)),
7211 },
7212 )
7213 .await?;
7214
7215 let slot = mgr.sessions.get(&pseudonym).context("the session slot must exist")?;
7216 let supervisor = slot
7217 .pix_supervisor
7218 .get()
7219 .context("an Exit-side PIX session must have a supervisor")?;
7220
7221 let ssa_id = SsaId::new(pseudonym, SsaIndex::new(1).expect("index one is non-zero"));
7222 for event in [
7223 SessionPixEvent::SsaRequestSent(ssa_id),
7224 SessionPixEvent::CommitmentVerified(ssa_id),
7225 SessionPixEvent::DepositConfirmed {
7226 ssa_id,
7227 amount: hopr_api::HoprBalance::new_base(1000),
7228 },
7229 ] {
7230 supervisor
7231 .send_event(event)
7232 .await
7233 .map_err(|()| anyhow::anyhow!("the supervisor stopped accepting events"))?;
7234 }
7235
7236 Ok((mgr, msg_rx, pseudonym))
7237 }
7238
7239 /// Counts the return-routed keep-alives `mgr` originated for `pseudonym` during `window`.
7240 async fn keep_alives_during(rx: &mut Originated, pseudonym: HoprPseudonym, window: Duration) -> usize {
7241 originated_during(rx, window)
7242 .await
7243 .iter()
7244 .filter(|(routing, data)| {
7245 msg_type(data, StartProtocolDiscriminants::KeepAlive)
7246 && matches!(routing, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if *p == pseudonym)
7247 })
7248 .count()
7249 }
7250
7251 /// A funded, idle PIX Session must be filled by the Exit at the rate its own deadline needs.
7252 ///
7253 /// This is the mechanism end to end on one node: the supervisor plans a rate, the action driver
7254 /// applies it to the shared keep-alive controller, the admission predicate lets the packets past,
7255 /// and they come out return-routed to the Entry. The floor asserted is deliberately the
7256 /// *notification* rate rather than the planned one — a regression that leaves the stream running
7257 /// at one packet a second is exactly the failure this exists to prevent, and it looks like
7258 /// success to any assertion that only checks the stream is alive.
7259 #[test_log::test(tokio::test)]
7260 async fn a_funded_idle_pix_session_is_filled_by_the_exit() -> anyhow::Result<()> {
7261 let notify = Duration::from_secs(1);
7262 let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7263 Capabilities::empty(),
7264 Some(notify),
7265 // Draining off, so the trailing close below still means "the stream stopped at once".
7266 // What a drain does instead is the subject of the tests further down.
7267 crate::supervision::PixFillConfig {
7268 min_surb_reserve: 1,
7269 drain_after_close: false,
7270 ..Default::default()
7271 },
7272 )
7273 .await?;
7274
7275 // SURBs to spend, so the reserve is not what this test is measuring.
7276 mgr.sessions
7277 .get(&pseudonym)
7278 .context("the session slot must exist")?
7279 .surb_estimator
7280 .produced
7281 .fetch_add(100_000, std::sync::atomic::Ordering::Relaxed);
7282
7283 // One sampling interval for the planner, then a window long enough that the notification
7284 // alone could not account for what is counted.
7285 let _ramp = originated_during(&mut msg_rx, Duration::from_millis(1500)).await;
7286 let window = Duration::from_millis(1500);
7287 let observed = keep_alives_during(&mut msg_rx, pseudonym, window).await;
7288
7289 // Counted against the rate the notification is *promoted* at rather than against the period,
7290 // for the reason `admit_at` documents: the classification window is one slack shorter than the
7291 // period, so a notification-only stream produces rather more than `window / period` packets.
7292 // Taking the period at face value here would set the floor too low and let a stream that had
7293 // stopped filling pass.
7294 let promote_every = notify - MAX_WAIT_CHUNK.min(notify / 2);
7295 let notify_only = (window.as_secs_f64() / promote_every.as_secs_f64()).ceil() as usize;
7296 assert!(
7297 observed > notify_only,
7298 "a funded idle cycle produced {observed} keep-alive(s) over {window:?}, which the {notify:?} SURB-level \
7299 notification alone could have produced ({notify_only}, one every {promote_every:?})"
7300 );
7301
7302 assert!(mgr.close_session(&pseudonym), "the session must exist to be closed");
7303 assert_no_further_origination(&mut msg_rx, "an explicit close of a filling PIX session").await;
7304 Ok(())
7305 }
7306
7307 // ---------------------------------------------------------------
7308 // Draining a closed PIX Session
7309 // ---------------------------------------------------------------
7310
7311 /// A funded, filling Exit PIX Session whose SURB estimate covers the rest of its cycle.
7312 ///
7313 /// The 100 000 is not decoration: it is the estimate `begin_surb_drain` nets the reserve out of
7314 /// and hands the supervisor, and against [`fill_pix_params`]' 300-packet cycle it is what makes a
7315 /// drain admissible at all. The ramp is waited out for the reason
7316 /// [`exit_session_originating_keep_alives`] gives — silence after a close says nothing unless the
7317 /// stream was observably running before it.
7318 async fn drainable_exit_pix_session(
7319 notify_period: Duration,
7320 ) -> anyhow::Result<(RecordingManager, Originated, HoprPseudonym)> {
7321 let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7322 Capabilities::empty(),
7323 Some(notify_period),
7324 crate::supervision::PixFillConfig {
7325 min_surb_reserve: 1,
7326 ..Default::default()
7327 },
7328 )
7329 .await?;
7330
7331 mgr.sessions
7332 .get(&pseudonym)
7333 .context("the session slot must exist")?
7334 .surb_estimator
7335 .produced
7336 .fetch_add(100_000, std::sync::atomic::Ordering::Relaxed);
7337
7338 let observed = keep_alives_during(&mut msg_rx, pseudonym, Duration::from_millis(1500)).await;
7339 anyhow::ensure!(
7340 observed > 0,
7341 "the fixture's keep-alive stream never ran, so nothing below can tell a drain from a Session that was \
7342 silent all along"
7343 );
7344 Ok((mgr, msg_rx, pseudonym))
7345 }
7346
7347 /// A closed PIX Session goes on originating until the cycle it was paid for recovers.
7348 ///
7349 /// The deposit is stranded rather than refunded if the cycle does not complete, so a close that
7350 /// tore the stream down while the Exit still held enough SURBs to finish would throw away money
7351 /// both sides have already paid. What must *not* happen is the mirror image of that: origination
7352 /// continuing past the point it is buying anything. So the recovery is delivered and the slot has
7353 /// to be gone, and the stream silent, on the other side of it.
7354 #[test_log::test(tokio::test)]
7355 async fn a_closed_pix_session_keeps_originating_until_its_cycle_recovers() -> anyhow::Result<()> {
7356 let notify = Duration::from_secs(1);
7357 let (mgr, mut msg_rx, pseudonym) = drainable_exit_pix_session(notify).await?;
7358
7359 assert!(
7360 mgr.close_session(&pseudonym),
7361 "a close that starts a drain is still a close that was handled"
7362 );
7363 assert!(
7364 mgr.active_sessions().contains(&pseudonym),
7365 "the slot must survive the close — the drain runs out of it, and the supervisor's own teardown is what \
7366 removes it"
7367 );
7368
7369 // The SURB-level notification is switched off for the whole drain, so every packet counted
7370 // here is fill. The bar is nevertheless the notification-only figure, which keeps this
7371 // comparable with `a_funded_idle_pix_session_is_filled_by_the_exit` above.
7372 let window = Duration::from_millis(1500);
7373 let observed = keep_alives_during(&mut msg_rx, pseudonym, window).await;
7374 let promote_every = notify - MAX_WAIT_CHUNK.min(notify / 2);
7375 let notify_only = (window.as_secs_f64() / promote_every.as_secs_f64()).ceil() as usize;
7376 assert!(
7377 observed > notify_only,
7378 "a closed session with a full SURB buffer originated {observed} keep-alive(s) over {window:?}, which is \
7379 no more than the {notify_only} its (now silenced) notification would have accounted for"
7380 );
7381
7382 // Recovery is the end of the drain: there is nothing left to work for, so the Session must go.
7383 let ssa_id = SsaId::new(pseudonym, SsaIndex::new(1).expect("index one is non-zero"));
7384 mgr.dispatch_pix_event(HoprSessionInPixEvent::SsaRecovered(ssa_id))
7385 .await?;
7386 assert!(
7387 wait_for_no_active_sessions(&mgr).await,
7388 "a recovered cycle must end the drain and tear the Session down"
7389 );
7390 assert_no_further_origination(&mut msg_rx, "a completed SURB drain").await;
7391 Ok(())
7392 }
7393
7394 /// A closed PIX Session with too few SURBs to finish is torn down as it always was.
7395 ///
7396 /// A hundred SURBs against [`fill_pix_params`]' 300-packet cycle: enough for the manager to offer
7397 /// the close to the supervisor, and not enough for the supervisor to take it. That is the
7398 /// arithmetic under test — draining on a buffer that runs out partway is strictly worse than not
7399 /// draining at all, since the SURBs are spent down to the reserve, the cycle recovers nothing,
7400 /// and every packet past the last useful one is return-routed on behalf of a Session that is gone.
7401 #[test_log::test(tokio::test)]
7402 async fn a_closed_pix_session_without_enough_surbs_originates_nothing() -> anyhow::Result<()> {
7403 let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7404 Capabilities::empty(),
7405 Some(MIN_SURB_BUFFER_NOTIFICATION_PERIOD),
7406 crate::supervision::PixFillConfig {
7407 min_surb_reserve: 1,
7408 ..Default::default()
7409 },
7410 )
7411 .await?;
7412
7413 mgr.sessions
7414 .get(&pseudonym)
7415 .context("the session slot must exist")?
7416 .surb_estimator
7417 .produced
7418 .fetch_add(100, std::sync::atomic::Ordering::Relaxed);
7419 anyhow::ensure!(
7420 keep_alives_during(&mut msg_rx, pseudonym, Duration::from_millis(1500)).await > 0,
7421 "the stream must be running before the close for its absence afterwards to mean anything"
7422 );
7423
7424 assert!(mgr.close_session(&pseudonym), "the session must exist to be closed");
7425 assert!(
7426 wait_for_no_active_sessions(&mgr).await,
7427 "a drain the supervisor refuses must still tear the Session down"
7428 );
7429 assert_no_further_origination(&mut msg_rx, "a close with too few SURBs to drain").await;
7430 Ok(())
7431 }
7432
7433 /// With `drain_after_close` off, every close path is what it was before draining existed.
7434 ///
7435 /// Including the part an operator would notice first: the slot is gone by the time
7436 /// `close_session` returns, rather than one channel hop later once the supervisor has answered.
7437 #[test_log::test(tokio::test)]
7438 async fn a_closed_pix_session_with_draining_disabled_originates_nothing() -> anyhow::Result<()> {
7439 let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7440 Capabilities::empty(),
7441 Some(Duration::from_secs(1)),
7442 crate::supervision::PixFillConfig {
7443 min_surb_reserve: 1,
7444 drain_after_close: false,
7445 ..Default::default()
7446 },
7447 )
7448 .await?;
7449
7450 mgr.sessions
7451 .get(&pseudonym)
7452 .context("the session slot must exist")?
7453 .surb_estimator
7454 .produced
7455 .fetch_add(100_000, std::sync::atomic::Ordering::Relaxed);
7456 anyhow::ensure!(
7457 keep_alives_during(&mut msg_rx, pseudonym, Duration::from_millis(1500)).await > 0,
7458 "the fixture's keep-alive stream never ran"
7459 );
7460
7461 assert!(mgr.close_session(&pseudonym), "the session must exist to be closed");
7462 assert!(
7463 mgr.active_sessions().is_empty(),
7464 "with draining disabled the slot must be gone by the time the close returns"
7465 );
7466 assert_no_further_origination(&mut msg_rx, "an explicit close with draining disabled").await;
7467 Ok(())
7468 }
7469
7470 /// Closing a draining Session a second time forces the teardown.
7471 ///
7472 /// The drain is the Exit's decision, taken on the operator's behalf, and this is the operator's
7473 /// way to overrule it: a second `SessionManager::close_session` — or a peer `SessionError`, which
7474 /// arrives on the same path — stops the origination immediately rather than waiting out a cycle.
7475 /// The end-of-stream a close of our own ingress produces is deliberately *not* on this path; it
7476 /// carries `WriteClosed`/`EmptyRead` and is absorbed.
7477 #[test_log::test(tokio::test)]
7478 async fn a_second_close_of_a_draining_session_tears_it_down() -> anyhow::Result<()> {
7479 let (mgr, mut msg_rx, pseudonym) = drainable_exit_pix_session(Duration::from_secs(1)).await?;
7480
7481 assert!(mgr.close_session(&pseudonym), "the first close starts the drain");
7482 assert!(
7483 mgr.active_sessions().contains(&pseudonym),
7484 "the drain must be running for the second close to have anything to overrule"
7485 );
7486
7487 assert!(mgr.close_session(&pseudonym), "the second close must find the session");
7488 assert!(
7489 mgr.active_sessions().is_empty(),
7490 "a second explicit close must remove the slot rather than let the drain run on"
7491 );
7492 assert_no_further_origination(&mut msg_rx, "a second explicit close of a draining session").await;
7493 Ok(())
7494 }
7495
7496 /// The same Session, with no SURBs to spend, must fall back to its SURB-level notification.
7497 ///
7498 /// A Session whose Entry has stopped supplying SURBs is the one case where filling harder is
7499 /// actively harmful: the packets cannot be routed, and an unroutable origination holds up every
7500 /// packet the node sends. What must survive is the notification, because it is the message that
7501 /// asks for the SURBs whose absence caused the backoff.
7502 #[test_log::test(tokio::test)]
7503 async fn fill_backs_off_to_the_notification_when_the_entry_supplies_no_surbs() -> anyhow::Result<()> {
7504 // At `MIN_SURB_BUFFER_NOTIFICATION_PERIOD`, so it survives the clamp `SessionManager::new`
7505 // applies. A shorter value would be raised to this one anyway, and the bound below would then
7506 // be computed from a period that is not the one in effect.
7507 let notify = MIN_SURB_BUFFER_NOTIFICATION_PERIOD;
7508 let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7509 Capabilities::empty(),
7510 Some(notify),
7511 crate::supervision::PixFillConfig {
7512 min_surb_reserve: 500,
7513 ..Default::default()
7514 },
7515 )
7516 .await?;
7517 assert_eq!(
7518 Some(notify),
7519 mgr.cfg.surb_balance_notify_period,
7520 "the fixture's notification period must be the one the manager actually runs"
7521 );
7522
7523 let _ramp = originated_during(&mut msg_rx, Duration::from_millis(1500)).await;
7524 let window = Duration::from_millis(1500);
7525 let observed = keep_alives_during(&mut msg_rx, pseudonym, window).await;
7526
7527 // A notification is promoted every `period - min(MAX_WAIT_CHUNK, period / 2)`, not every
7528 // period: `admit_at` opens the window a slack early so the classification does not rest on the
7529 // rate limiter's scheduling latency. The ceiling has to be computed from that shorter interval,
7530 // or a stream doing exactly what this test permits would be read as filling.
7531 let promote_every = notify - MAX_WAIT_CHUNK.min(notify / 2);
7532 let notify_only = (window.as_secs_f64() / promote_every.as_secs_f64()).ceil() as usize;
7533 assert!(
7534 observed > 0,
7535 "the SURB-level notification must survive the backoff — it is what asks for the SURBs"
7536 );
7537 assert!(
7538 observed <= notify_only + 1,
7539 "with no SURBs to spend the Exit originated {observed} keep-alive(s) over {window:?}, against the \
7540 {notify_only} its notification alone accounts for at one every {promote_every:?}"
7541 );
7542
7543 assert!(mgr.close_session(&pseudonym));
7544 Ok(())
7545 }
7546
7547 /// A `NoRateControl` PIX Session must still have a SURB level estimate, or its reserve is a lie.
7548 ///
7549 /// That branch runs no balancer, and until now it wired no estimator either: `produced` and
7550 /// `consumed` both stayed at zero for the life of the Session. A reserve measured against that
7551 /// counter would refuse every fill packet forever — which is the branch that can drain SURBs
7552 /// fastest, so it is the one where getting it wrong costs most.
7553 #[test_log::test(tokio::test)]
7554 async fn a_no_rate_control_pix_session_still_estimates_its_surb_level() -> anyhow::Result<()> {
7555 let (mgr, mut msg_rx, pseudonym) = recovering_exit_pix_session(
7556 Capability::NoRateControl.into(),
7557 Some(Duration::from_secs(1)),
7558 crate::supervision::PixFillConfig {
7559 min_surb_reserve: 1,
7560 ..Default::default()
7561 },
7562 )
7563 .await?;
7564
7565 let slot = mgr.sessions.get(&pseudonym).context("the session slot must exist")?;
7566 slot.surb_estimator
7567 .produced
7568 .fetch_add(100_000, std::sync::atomic::Ordering::Relaxed);
7569
7570 let _ = keep_alives_during(&mut msg_rx, pseudonym, Duration::from_millis(1500)).await;
7571
7572 assert!(
7573 slot.surb_estimator.consumed.load(std::sync::atomic::Ordering::Relaxed) > 0,
7574 "a NoRateControl Session that has sent keep-alives must have counted them as consumed SURBs"
7575 );
7576
7577 assert!(mgr.close_session(&pseudonym));
7578 Ok(())
7579 }
7580
7581 /// Verifies that established sessions exchange `KeepAlive` messages driven by the SURB balancer,
7582 /// that config updates propagate via keep-alives, and that SURB usage statistics are collected.
7583 ///
7584 /// ## Steps
7585 /// 1. Alice's manager is started with no `PixToolbox` and a `SurbBalancerConfig` with `target_surb_buffer_size:
7586 /// 10`. Bob's manager is configured with a 500ms `surb_balance_notify_period`.
7587 /// 2. Alice initiates a session with the balancer config and PIX quota set; the `StartSession` /
7588 /// `SessionEstablished` handshake completes via mock transports.
7589 /// 3. Both managers report the same `target_surb_buffer_size` via `get_surb_balancer_config` (confirmed from both
7590 /// Alice and Bob's perspective).
7591 /// 4. A 1500ms sleep allows the SURB balancer's periodic keep-alive timer to fire multiple times.
7592 /// 5. `update_surb_balancer_config` is called to raise the target to 50. After another 1500ms, Bob's manager
7593 /// reflects the updated target via `get_surb_balancer_config`, confirming keep-alives communicated the change.
7594 /// 6. `get_surb_level_estimates` is called on both sides; both report positive sent/received/used counts,
7595 /// confirming the balancer collected SURB statistics.
7596 /// 7. Alice closes the session; `ping_session` returns `NonExistingSession` after a short wait.
7597 #[test_log::test(tokio::test)]
7598 async fn session_manager_should_send_keep_alives_via_surb_balancer() -> anyhow::Result<()> {
7599 let alice_pseudonym = HoprPseudonym::random();
7600 let bob_peer: Address = (&ChainKeypair::random()).into();
7601
7602 let bob_cfg = SessionManagerConfig {
7603 surb_balance_notify_period: Some(Duration::from_millis(500)),
7604 ..Default::default()
7605 };
7606 let alice_mgr = SessionManager::new(Default::default());
7607 let bob_mgr = SessionManager::new(bob_cfg.clone());
7608
7609 let mut alice_transport = MockMsgSender::new();
7610 let mut bob_transport = MockMsgSender::new();
7611
7612 // Alice sends the StartSession message
7613 let mut open_sequence = mockall::Sequence::new();
7614 let bob_mgr_clone = bob_mgr.clone();
7615 alice_transport
7616 .expect_send_message()
7617 .once()
7618 .in_sequence(&mut open_sequence)
7619 .withf(move |peer, data| {
7620 msg_type(data, StartProtocolDiscriminants::StartSession)
7621 && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
7622 })
7623 .returning(move |_, data| {
7624 let bob_mgr_clone = bob_mgr_clone.clone();
7625 Box::pin(async move {
7626 bob_mgr_clone
7627 .dispatch_message(
7628 alice_pseudonym,
7629 ApplicationDataIn {
7630 data: data.data,
7631 packet_info: Default::default(),
7632 },
7633 )
7634 ?;
7635 Ok(())
7636 })
7637 });
7638
7639 // Bob sends the SessionEstablished message
7640 let alice_mgr_clone = alice_mgr.clone();
7641 bob_transport
7642 .expect_send_message()
7643 .once()
7644 .in_sequence(&mut open_sequence)
7645 .withf(move |peer, data| {
7646 msg_type(data, StartProtocolDiscriminants::SessionEstablished)
7647 && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if *p == alice_pseudonym)
7648 })
7649 .returning(move |_, data| {
7650 let alice_mgr_clone = alice_mgr_clone.clone();
7651 Box::pin(async move {
7652 alice_mgr_clone.dispatch_message(
7653 alice_pseudonym,
7654 ApplicationDataIn {
7655 data: data.data,
7656 packet_info: Default::default(),
7657 },
7658 )?;
7659 Ok(())
7660 })
7661 });
7662
7663 const INITIAL_BALANCER_TARGET: u64 = 10;
7664
7665 // Alice sends the KeepAlive messages reporting the initial balancer target
7666 let bob_mgr_clone = bob_mgr.clone();
7667 alice_transport
7668 .expect_send_message()
7669 .times(1..)
7670 //.in_sequence(&mut sequence)
7671 .withf(move |peer, data| {
7672 start_msg_match(data, |msg| matches!(msg, StartProtocol::KeepAlive(ka) if ka.flags.contains(KeepAliveFlag::BalancerTarget) && ka.additional_data == INITIAL_BALANCER_TARGET))
7673 //msg_type(data, StartProtocolDiscriminants::KeepAlive)
7674 && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
7675 })
7676 .returning(move |_, data| {
7677 let bob_mgr_clone = bob_mgr_clone.clone();
7678 Box::pin(async move {
7679 bob_mgr_clone
7680 .dispatch_message(
7681 alice_pseudonym,
7682 ApplicationDataIn {
7683 data: data.data,
7684 packet_info: Default::default(),
7685 },
7686 )
7687 ?;
7688 Ok(())
7689 })
7690 });
7691
7692 const NEXT_BALANCER_TARGET: u64 = 50;
7693
7694 // Alice sends also the KeepAlive messages reporting the updated balancer target
7695 let bob_mgr_clone = bob_mgr.clone();
7696 alice_transport
7697 .expect_send_message()
7698 .times(1..)
7699 //.in_sequence(&mut sequence)
7700 .withf(move |peer, data| {
7701 start_msg_match(data, |msg| matches!(msg, StartProtocol::KeepAlive(ka) if ka.flags.contains(KeepAliveFlag::BalancerTarget) && ka.additional_data == NEXT_BALANCER_TARGET))
7702 && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
7703 })
7704 .returning(move |_, data| {
7705 let bob_mgr_clone = bob_mgr_clone.clone();
7706 Box::pin(async move {
7707 bob_mgr_clone
7708 .dispatch_message(
7709 alice_pseudonym,
7710 ApplicationDataIn {
7711 data: data.data,
7712 packet_info: Default::default(),
7713 },
7714 )
7715 ?;
7716 Ok(())
7717 })
7718 });
7719
7720 // Bob sends at least 1 Keep Alive back reporting its SURB estimate
7721 let alice_mgr_clone = alice_mgr.clone();
7722 bob_transport
7723 .expect_send_message()
7724 .times(1..)
7725 //.in_sequence(&mut open_sequence)
7726 .withf(move |peer, data| {
7727 start_msg_match(data, |msg| matches!(msg, StartProtocol::KeepAlive(ka) if ka.flags.contains(KeepAliveFlag::BalancerState) && ka.additional_data > 0))
7728 && matches!(peer, DestinationRouting::Return(SurbMatcher::Pseudonym(p)) if *p == alice_pseudonym)
7729 })
7730 .returning(move |_, data| {
7731 let alice_mgr_clone = alice_mgr_clone.clone();
7732 Box::pin(async move {
7733 alice_mgr_clone
7734 .dispatch_message(
7735 alice_pseudonym,
7736 ApplicationDataIn {
7737 data: data.data,
7738 packet_info: Default::default(),
7739 },
7740 )
7741 ?;
7742 Ok(())
7743 })
7744 });
7745
7746 // Alice sends the terminating segment to close the Session
7747 let bob_mgr_clone = bob_mgr.clone();
7748 alice_transport
7749 .expect_send_message()
7750 .once()
7751 //.in_sequence(&mut sequence)
7752 .withf(move |peer, data| {
7753 hopr_protocol_session::types::SessionMessage::<{ ApplicationData::PAYLOAD_SIZE }>::try_from(
7754 data.data.plain_text.as_ref(),
7755 )
7756 .ok()
7757 .and_then(|m| m.try_as_segment())
7758 .map(|s| s.is_terminating())
7759 .unwrap_or(false)
7760 && matches!(peer, DestinationRouting::Forward { destination, .. } if destination.as_ref() == &bob_peer.into())
7761 })
7762 .returning(move |_, data| {
7763 let bob_mgr_clone = bob_mgr_clone.clone();
7764 Box::pin(async move {
7765 bob_mgr_clone
7766 .dispatch_message(
7767 alice_pseudonym,
7768 ApplicationDataIn {
7769 data: data.data,
7770 packet_info: Default::default(),
7771 },
7772 )
7773 ?;
7774 Ok(())
7775 })
7776 });
7777
7778 let mut ahs = Vec::new();
7779
7780 // Start Alice
7781 let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
7782 let (alice_sender, alice_handle) = mock_packet_planning(alice_transport);
7783 ahs.extend(alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?);
7784 assert!(alice_mgr.is_started());
7785
7786 // Start Bob
7787 let (new_session_tx_bob, new_session_rx_bob) = futures::channel::mpsc::channel(1024);
7788 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
7789 ahs.extend(bob_mgr.start(bob_sender.clone(), new_session_tx_bob, None, None)?);
7790 assert!(bob_mgr.is_started());
7791
7792 let target = SealedHost::Plain("127.0.0.1:80".parse()?);
7793
7794 let balancer_cfg = SurbBalancerConfig {
7795 target_surb_buffer_size: INITIAL_BALANCER_TARGET,
7796 max_surbs_per_sec: 100,
7797 ..Default::default()
7798 };
7799
7800 pin_mut!(new_session_rx_bob);
7801 let (alice_session, bob_session) = timeout(
7802 Duration::from_secs(2),
7803 futures::future::join(
7804 alice_mgr.new_session(
7805 bob_peer,
7806 SessionTarget::TcpStream(target.clone()),
7807 SessionClientConfig {
7808 pseudonym: alice_pseudonym.into(),
7809 capabilities: Capability::Segmentation.into(),
7810 surb_management: Some(balancer_cfg),
7811 ..Default::default()
7812 },
7813 ),
7814 new_session_rx_bob.next(),
7815 ),
7816 )
7817 .await?;
7818
7819 let mut alice_session = alice_session?;
7820 let bob_session = bob_session.ok_or(anyhow!("bob must get an incoming session"))?;
7821
7822 assert!(matches!(bob_session.target, SessionTarget::TcpStream(host) if host == target));
7823
7824 assert_eq!(
7825 Some(balancer_cfg),
7826 alice_mgr.get_surb_balancer_config(alice_session.id())?
7827 );
7828
7829 // The organic SURB gate, on the real slot rather than a hand-built state. Two things it must
7830 // get right, both of which would deadlock a Session if inverted.
7831 {
7832 let alice_slot = alice_mgr
7833 .sessions
7834 .get(alice_session.id())
7835 .ok_or(anyhow!("alice must hold the session slot"))?;
7836
7837 // At cold start the gate must be open: readiness only waits for half the target, so a
7838 // gate that closed at or below that would stop the very production it is waiting on.
7839 assert_eq!(
7840 1,
7841 alice_slot.surb_mgmt.organic_surbs_per_packet(),
7842 "a freshly opened session must still produce organic SURBs"
7843 );
7844
7845 // And it must stay open on the counterparty's word, whatever the local estimate says.
7846 alice_slot
7847 .surb_mgmt
7848 .observe_counterparty_signals(PacketSignal::OutOfSurbs.into());
7849 alice_slot
7850 .surb_mgmt
7851 .buffer_level
7852 .store(u64::MAX, std::sync::atomic::Ordering::Relaxed);
7853 assert_eq!(
7854 1,
7855 alice_slot.surb_mgmt.organic_surbs_per_packet(),
7856 "a counterparty out of SURBs must override any local estimate"
7857 );
7858
7859 // Leave the slot as it was found; the balancer loop keeps running below.
7860 alice_slot
7861 .surb_mgmt
7862 .observe_counterparty_signals(PacketSignals::default());
7863 alice_slot
7864 .surb_mgmt
7865 .buffer_level
7866 .store(0, std::sync::atomic::Ordering::Relaxed);
7867 }
7868
7869 let remote_cfg = bob_mgr
7870 .get_surb_balancer_config(bob_session.session.id())?
7871 .ok_or(anyhow!("no remote config at bob"))?;
7872 assert_eq!(remote_cfg.target_surb_buffer_size, balancer_cfg.target_surb_buffer_size);
7873 assert_eq!(
7874 remote_cfg.max_surbs_per_sec,
7875 remote_cfg.target_surb_buffer_size
7876 / bob_cfg
7877 .minimum_surb_buffer_duration
7878 .max(MIN_SURB_BUFFER_DURATION)
7879 .as_secs()
7880 );
7881
7882 // Let the Surb balancer send enough KeepAlive messages
7883 tokio::time::sleep(Duration::from_millis(1500)).await;
7884
7885 let new_balancer_cfg = SurbBalancerConfig {
7886 target_surb_buffer_size: NEXT_BALANCER_TARGET,
7887 max_surbs_per_sec: 100,
7888 ..Default::default()
7889 };
7890
7891 // Update to a higher target
7892 alice_mgr.update_surb_balancer_config(alice_session.id(), new_balancer_cfg)?;
7893
7894 // Let the Surb balancer send enough KeepAlive messages
7895 tokio::time::sleep(Duration::from_millis(1500)).await;
7896
7897 // Bob should know about the updated target
7898 let remote_cfg = bob_mgr
7899 .get_surb_balancer_config(bob_session.session.id())?
7900 .ok_or(anyhow!("no remote config at bob"))?;
7901 assert_eq!(
7902 remote_cfg.target_surb_buffer_size,
7903 new_balancer_cfg.target_surb_buffer_size
7904 );
7905 assert_eq!(
7906 remote_cfg.max_surbs_per_sec,
7907 new_balancer_cfg.target_surb_buffer_size / bob_cfg.minimum_surb_buffer_duration.as_secs()
7908 );
7909
7910 let (alice_surb_sent, alice_surb_used) = alice_mgr.get_surb_level_estimates(alice_session.id())?;
7911 let (bob_surb_recv, bob_surb_used) = bob_mgr.get_surb_level_estimates(bob_session.session.id())?;
7912
7913 alice_session.close().await?;
7914
7915 assert!(alice_surb_sent > 0, "alice must've sent surbs");
7916 assert!(bob_surb_recv > 0, "bob must've received surbs");
7917 assert!(
7918 bob_surb_recv <= alice_surb_sent,
7919 "bob cannot receive more surbs than alice sent"
7920 );
7921
7922 assert!(alice_surb_used > 0, "alice must see bob used surbs");
7923 assert!(bob_surb_used > 0, "bob must've used surbs");
7924 assert!(
7925 alice_surb_used <= bob_surb_used,
7926 "alice cannot see bob used more surbs than bob actually used"
7927 );
7928
7929 tokio::time::sleep(Duration::from_millis(300)).await;
7930 assert!(matches!(
7931 alice_mgr.ping_session(alice_session.id()).await,
7932 Err(TransportSessionError::Manager(SessionManagerError::NonExistingSession))
7933 ));
7934
7935 futures::stream::iter(ahs)
7936 .for_each(|ah| async move { ah.abort() })
7937 .await;
7938
7939 // Cleanup: close senders and await handles
7940 alice_sender.close_channel();
7941 bob_sender.close_channel();
7942 alice_handle.await??;
7943 bob_handle.await??;
7944
7945 Ok(())
7946 }
7947
7948 /// Verifies that a second incoming session initiation for the same pseudonym is handled gracefully
7949 /// (returns `Ok`) without creating a duplicate session slot.
7950 ///
7951 /// ## Steps
7952 /// 1. A `SessionManager` is started with a mock transport that accepts two outbound messages.
7953 /// 2. `handle_incoming_session_initiation` is called with pseudonym `X` — succeeds; exactly one active session is
7954 /// confirmed.
7955 /// 3. `handle_incoming_session_initiation` is called again with the same pseudonym `X`. The manager detects the
7956 /// conflict and handles it internally by sending a `SessionError` to the peer.
7957 /// 4. The call still returns `Ok` (error is handled internally); `num_active_sessions` remains 1 with only the
7958 /// original pseudonym present.
7959 #[test_log::test(tokio::test)]
7960 async fn session_manager_should_reject_duplicate_session_for_same_pseudonym() -> anyhow::Result<()> {
7961 use hopr_utils::network_types::prelude::SealedHost;
7962
7963 let bob_mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
7964 SessionManager::new(Default::default());
7965
7966 // Start the manager (required for handling incoming sessions)
7967 let mut transport = MockMsgSender::new();
7968 transport
7969 .expect_send_message()
7970 .times(2)
7971 .returning(|_, _| futures::future::ok(()).boxed());
7972
7973 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
7974 // Spawn a task to receive new session notifications
7975 let _notifications = tokio::spawn(async move {
7976 pin_mut!(new_session_rx);
7977 while let Some(_session) = new_session_rx.next().await {
7978 // Just drain the channel
7979 }
7980 });
7981 let (sender, _handle) = mock_packet_planning(transport);
7982 bob_mgr.start(sender.clone(), new_session_tx, None, None)?;
7983 assert!(bob_mgr.is_started());
7984
7985 let pseudonym = HoprPseudonym::random();
7986
7987 // First session initiation - should succeed
7988 let result = bob_mgr
7989 .handle_incoming_session_initiation(
7990 pseudonym,
7991 StartInitiation {
7992 challenge: MIN_CHALLENGE,
7993 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
7994 capabilities: HoprSessionCapabilities::empty(),
7995 additional_data: 0,
7996 },
7997 )
7998 .await;
7999
8000 assert!(result.is_ok(), "first session initiation should succeed");
8001
8002 // Verify one session exists
8003 let active = bob_mgr.active_sessions();
8004 assert_eq!(active.len(), 1, "should have exactly one active session");
8005 assert_eq!(bob_mgr.num_active_sessions(), 1);
8006
8007 // Second session initiation with same pseudonym - should be handled gracefully
8008 // (returns Ok but sends SessionError to the requester)
8009 let result = bob_mgr
8010 .handle_incoming_session_initiation(
8011 pseudonym,
8012 StartInitiation {
8013 challenge: MIN_CHALLENGE,
8014 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
8015 capabilities: HoprSessionCapabilities::empty(),
8016 additional_data: 0,
8017 },
8018 )
8019 .await;
8020
8021 // The second initiation returns Ok but handles the duplicate by sending SessionError
8022 assert!(
8023 result.is_ok(),
8024 "second session initiation should return Ok (error is handled internally)"
8025 );
8026
8027 // Verify still only one session exists
8028 let active = bob_mgr.active_sessions();
8029 assert_eq!(active.len(), 1, "should still have exactly one active session");
8030 assert_eq!(bob_mgr.num_active_sessions(), 1);
8031
8032 // Cleanup: close sender and await handle
8033 sender.close_channel();
8034 _handle.await??;
8035
8036 Ok(())
8037 }
8038
8039 /// Verifies that pinging a session that does not exist returns `NonExistingSession`.
8040 ///
8041 /// ## Steps
8042 /// 1. A `SessionManager` is started with a mock transport.
8043 /// 2. `ping_session` is called with a completely random (non-existent) session ID.
8044 /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::NonExistingSession)`.
8045 /// 4. `num_active_sessions` is 0, confirming no sessions were created.
8046 #[test_log::test(tokio::test)]
8047 async fn session_manager_should_return_error_when_pinging_non_existent_session() -> anyhow::Result<()> {
8048 let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8049 SessionManager::new(Default::default());
8050
8051 let transport = MockMsgSender::new();
8052 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8053 let _notifications = tokio::spawn(async move {
8054 pin_mut!(new_session_rx);
8055 while let Some(_session) = new_session_rx.next().await {}
8056 });
8057 let (sender, _handle) = mock_packet_planning(transport);
8058 mgr.start(sender.clone(), new_session_tx, None, None)?;
8059 assert!(mgr.is_started());
8060
8061 let fake_session_id = HoprPseudonym::random();
8062 assert_eq!(mgr.num_active_sessions(), 0);
8063 let result = mgr.ping_session(&fake_session_id).await;
8064
8065 assert!(result.is_err());
8066 assert!(matches!(
8067 result.unwrap_err(),
8068 TransportSessionError::Manager(SessionManagerError::NonExistingSession)
8069 ));
8070
8071 // Cleanup: close sender and await handle
8072 sender.close_channel();
8073 _handle.await??;
8074
8075 Ok(())
8076 }
8077
8078 /// Verifies that closing a session that does not exist returns `false` (no-op).
8079 ///
8080 /// ## Steps
8081 /// 1. A `SessionManager` is started with a mock transport.
8082 /// 2. `close_session` is called with a random (non-existent) session ID.
8083 /// 3. The call returns `false`, indicating no session was closed.
8084 #[test_log::test(tokio::test)]
8085 async fn session_manager_should_return_false_when_closing_non_existent_session() -> anyhow::Result<()> {
8086 let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8087 SessionManager::new(Default::default());
8088
8089 let transport = MockMsgSender::new();
8090 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8091 let _notifications = tokio::spawn(async move {
8092 pin_mut!(new_session_rx);
8093 while let Some(_session) = new_session_rx.next().await {}
8094 });
8095 let (sender, _handle) = mock_packet_planning(transport);
8096 mgr.start(sender.clone(), new_session_tx, None, None)?;
8097 assert!(mgr.is_started());
8098
8099 let fake_session_id = HoprPseudonym::random();
8100 assert_eq!(mgr.num_active_sessions(), 0);
8101 let result = mgr.close_session(&fake_session_id);
8102
8103 assert!(!result, "closing non-existent session should return false");
8104
8105 Ok(())
8106 }
8107
8108 /// Verifies that updating the SURB balancer config for a non-existent session returns an error.
8109 ///
8110 /// ## Steps
8111 /// 1. A `SessionManager` is started with a mock transport.
8112 /// 2. `update_surb_balancer_config` is called with a random session ID.
8113 /// 3. The call returns an error (no `Ok` variant is expected).
8114 #[test_log::test(tokio::test)]
8115 async fn session_manager_should_return_error_when_updating_surb_config_for_non_existent_session()
8116 -> anyhow::Result<()> {
8117 let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8118 SessionManager::new(Default::default());
8119
8120 let transport = MockMsgSender::new();
8121 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8122 let _notifications = tokio::spawn(async move {
8123 pin_mut!(new_session_rx);
8124 while let Some(_session) = new_session_rx.next().await {}
8125 });
8126 let (sender, _handle) = mock_packet_planning(transport);
8127 mgr.start(sender.clone(), new_session_tx, None, None)?;
8128 assert!(mgr.is_started());
8129
8130 let fake_session_id = HoprPseudonym::random();
8131 let result = mgr.update_surb_balancer_config(&fake_session_id, SurbBalancerConfig::default());
8132
8133 assert!(result.is_err());
8134
8135 // Cleanup: close sender and await handle
8136 sender.close_channel();
8137 _handle.await??;
8138
8139 Ok(())
8140 }
8141
8142 /// Verifies that retrieving the SURB balancer config for a non-existent session returns an error.
8143 ///
8144 /// ## Steps
8145 /// 1. A `SessionManager` is started with a mock transport.
8146 /// 2. `get_surb_balancer_config` is called with a random session ID.
8147 /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::NonExistingSession)`.
8148 #[test_log::test(tokio::test)]
8149 async fn session_manager_should_return_error_when_getting_surb_config_for_non_existent_session()
8150 -> anyhow::Result<()> {
8151 let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8152 SessionManager::new(Default::default());
8153
8154 let transport = MockMsgSender::new();
8155 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8156 let _notifications = tokio::spawn(async move {
8157 pin_mut!(new_session_rx);
8158 while let Some(_session) = new_session_rx.next().await {}
8159 });
8160 let (sender, _handle) = mock_packet_planning(transport);
8161 mgr.start(sender.clone(), new_session_tx, None, None)?;
8162 assert!(mgr.is_started());
8163
8164 let fake_session_id = HoprPseudonym::random();
8165 let result = mgr.get_surb_balancer_config(&fake_session_id);
8166
8167 assert!(result.is_err());
8168 assert!(matches!(
8169 result.unwrap_err(),
8170 TransportSessionError::Manager(SessionManagerError::NonExistingSession)
8171 ));
8172
8173 // Cleanup: close sender and await handle
8174 sender.close_channel();
8175 _handle.await??;
8176
8177 Ok(())
8178 }
8179
8180 /// Verifies that retrieving SURB level estimates for a non-existent session returns an error.
8181 ///
8182 /// ## Steps
8183 /// 1. A `SessionManager` is started with a mock transport.
8184 /// 2. `get_surb_level_estimates` is called with a random session ID.
8185 /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::NonExistingSession)`.
8186 #[test_log::test(tokio::test)]
8187 async fn session_manager_should_return_error_when_getting_surb_estimates_for_non_existent_session()
8188 -> anyhow::Result<()> {
8189 let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8190 SessionManager::new(Default::default());
8191
8192 let transport = MockMsgSender::new();
8193 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8194 let _notifications = tokio::spawn(async move {
8195 pin_mut!(new_session_rx);
8196 while let Some(_session) = new_session_rx.next().await {}
8197 });
8198 let (sender, _handle) = mock_packet_planning(transport);
8199 mgr.start(sender.clone(), new_session_tx, None, None)?;
8200 assert!(mgr.is_started());
8201
8202 let fake_session_id = HoprPseudonym::random();
8203 let result = mgr.get_surb_level_estimates(&fake_session_id);
8204
8205 assert!(result.is_err());
8206 assert!(matches!(
8207 result.unwrap_err(),
8208 TransportSessionError::Manager(SessionManagerError::NonExistingSession)
8209 ));
8210
8211 // Cleanup: close sender and await handle
8212 sender.close_channel();
8213 _handle.await??;
8214
8215 Ok(())
8216 }
8217
8218 /// Verifies the `HoprStartProtocol::SessionError` match arm (line 689) in the
8219 /// `session_start_protocol_processor` task by calling `handle_session_error` directly.
8220 ///
8221 /// When a `SessionError` message is delivered while a `new_session` call is awaiting,
8222 /// `handle_session_error` retrieves the pending challenge from `session_initiations`,
8223 /// sends the error down the channel, and `new_session` propagates it as `Rejected`.
8224 #[test_log::test(tokio::test)]
8225 async fn handle_session_error_propagates_peer_rejection_to_pending_new_session() -> anyhow::Result<()> {
8226 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8227 SessionManager::new(Default::default());
8228
8229 let mut transport = MockMsgSender::new();
8230 // new_session sends StartSession (succeeds), then waits for SessionEstablished.
8231 // We inject the error before it arrives.
8232 transport
8233 .expect_send_message()
8234 .returning(|_, _| futures::future::ok(()).boxed());
8235
8236 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8237 let _notifications = tokio::spawn(async move {
8238 pin_mut!(new_session_rx);
8239 while let Some(_session) = new_session_rx.next().await {}
8240 });
8241 let (sender, _handle) = mock_packet_planning(transport);
8242 mgr.start(sender.clone(), new_session_tx, None, None)?;
8243 assert!(mgr.is_started());
8244
8245 // Spawn new_session so it is blocked waiting for the session establishment response.
8246 let mgr_clone = mgr.clone();
8247 let peer_address: Address = (&ChainKeypair::random()).into();
8248 let handle = tokio::spawn(async move {
8249 mgr_clone
8250 .new_session(
8251 peer_address,
8252 SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
8253 SessionClientConfig {
8254 surb_management: None,
8255 ..Default::default()
8256 },
8257 )
8258 .await
8259 });
8260
8261 // Give new_session time to insert the challenge into session_initiations.
8262 let challenge = tokio::time::timeout(Duration::from_secs(1), async {
8263 loop {
8264 if let Some((ch, _)) = mgr.session_initiations.iter().next() {
8265 break *ch;
8266 }
8267 tokio::time::sleep(Duration::from_millis(10)).await;
8268 }
8269 })
8270 .await
8271 .context("new_session did not insert a challenge into session_initiations")?;
8272
8273 // Inject the SessionError with the matching challenge before SessionEstablished arrives.
8274 let error_type = StartErrorType {
8275 identifier: ErrorIdentifier::Challenge(challenge),
8276 reason: StartErrorReason::NoSlotsAvailable,
8277 };
8278 mgr.handle_session_error(error_type).await?;
8279
8280 // new_session must propagate the error as Rejected.
8281 let result = handle.await?;
8282 match result {
8283 Ok(_session) => panic!("expected rejection error, got session"),
8284 Err(e) => {
8285 assert!(matches!(
8286 e,
8287 TransportSessionError::Rejected(StartErrorReason::NoSlotsAvailable)
8288 ));
8289 }
8290 }
8291
8292 sender.close_channel();
8293 _handle.await??;
8294 Ok(())
8295 }
8296
8297 /// Verifies that an incoming session initiation is rejected (handled internally) when the
8298 /// manager already has `maximum_sessions` active sessions.
8299 ///
8300 /// ## Steps
8301 /// 1. A `SessionManager` is configured with `maximum_sessions: 1`.
8302 /// 2. `handle_incoming_session_initiation` is called with pseudonym `X1` — succeeds; one active session confirmed.
8303 /// 3. `handle_incoming_session_initiation` is called with pseudonym `X2` — the manager detects it is at capacity
8304 /// and handles the conflict internally (sends `SessionError` to peer).
8305 /// 4. The call returns `Ok` (handled internally); `num_active_sessions` remains 1, with only `X1` present — `X2`
8306 /// was rejected without creating a slot.
8307 #[test_log::test(tokio::test)]
8308 async fn session_manager_should_reject_new_session_when_max_sessions_reached() -> anyhow::Result<()> {
8309 use hopr_utils::network_types::prelude::SealedHost;
8310
8311 // Create manager with max 1 session
8312 let cfg = SessionManagerConfig {
8313 maximum_sessions: 1,
8314 ..Default::default()
8315 };
8316 let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8317 SessionManager::new(cfg);
8318
8319 let mut transport = MockMsgSender::new();
8320 transport
8321 .expect_send_message()
8322 .times(2)
8323 .returning(|_, _| futures::future::ok(()).boxed());
8324
8325 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8326 let _notifications = tokio::spawn(async move {
8327 pin_mut!(new_session_rx);
8328 while let Some(_session) = new_session_rx.next().await {}
8329 });
8330 let (sender, _handle) = mock_packet_planning(transport);
8331 mgr.start(sender.clone(), new_session_tx, None, None)?;
8332 assert!(mgr.is_started());
8333
8334 // First session - should succeed
8335 let pseudonym1 = HoprPseudonym::random();
8336 mgr.handle_incoming_session_initiation(
8337 pseudonym1,
8338 StartInitiation {
8339 challenge: MIN_CHALLENGE,
8340 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
8341 capabilities: HoprSessionCapabilities::empty(),
8342 additional_data: 0,
8343 },
8344 )
8345 .await?;
8346
8347 // Verify one session exists
8348 assert_eq!(mgr.active_sessions().len(), 1);
8349 assert_eq!(mgr.num_active_sessions(), 1);
8350
8351 // Second session - should fail with TooManySessions
8352 let pseudonym2 = HoprPseudonym::random();
8353 let _result = mgr
8354 .handle_incoming_session_initiation(
8355 pseudonym2,
8356 StartInitiation {
8357 challenge: MIN_CHALLENGE,
8358 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
8359 capabilities: HoprSessionCapabilities::empty(),
8360 additional_data: 0,
8361 },
8362 )
8363 .await;
8364
8365 // The error is handled internally (sends SessionError), so result is Ok
8366 // But we can verify no new session was added
8367 assert_eq!(mgr.active_sessions().len(), 1);
8368 assert_eq!(mgr.num_active_sessions(), 1);
8369
8370 // Cleanup: close sender and await handle
8371 sender.close_channel();
8372 _handle.await??;
8373
8374 Ok(())
8375 }
8376
8377 /// The node refuses a PIX Session it has no reconstructor memory left for, and does so before
8378 /// allocating anything.
8379 ///
8380 /// This is the bound `maximum_sessions` cannot express: a session slot is one slot whatever the
8381 /// peer offered, while the reconstructor state behind it is set by the peer's dimensions. Here
8382 /// `maximum_sessions` is wide open and the budget is exactly one Session's worth, so nothing but
8383 /// the budget can be doing the refusing.
8384 ///
8385 /// Refused, not queued: nothing later in establishment can give the memory back, and the peer is
8386 /// free to retry against another Exit.
8387 #[test_log::test(tokio::test)]
8388 async fn a_pix_session_over_the_live_cycle_budget_is_refused() -> anyhow::Result<()> {
8389 use hopr_utils::network_types::prelude::SealedHost;
8390
8391 // Four shares has only one legal dimension split: 2 polynomials at threshold 2. Using no
8392 // surplus makes this offer the worst-case reservation at its exact quota, so the configured
8393 // budget can honestly admit the whole range while still fitting exactly one such Session.
8394 let params = PixParams::try_new(2, 2, 0, LOCAL_PIX_SUITE)?;
8395 let quota = pix_params_to_quota(¶ms);
8396 let one_session = cycle_budget_for(¶ms, DEFAULT_SSAS_PER_SSA_REQUEST);
8397 assert_eq!(one_session, max_cycle_budget_for_quota(quota, 1));
8398
8399 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
8400 SessionManager::new(SessionManagerConfig {
8401 maximum_sessions: 100,
8402 pix_config: IncomingSessionPixConfig {
8403 quota_range: quota..=quota,
8404 // Room for exactly one Session at these dimensions.
8405 max_live_cycle_bytes: one_session,
8406 ..Default::default()
8407 },
8408 ..Default::default()
8409 });
8410
8411 let ssa_gen_config = SsaGeneratorConfig {
8412 polynomials_per_ssa: 2,
8413 threshold: 2,
8414 surplus_shares: 0,
8415 };
8416 let (pix_toolbox, _pix_events_rx) = PixToolbox::new(
8417 SsaShareGenerator::new(ssa_gen_config).into(),
8418 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
8419 );
8420
8421 let mut transport = MockMsgSender::new();
8422 transport
8423 .expect_send_message()
8424 .returning(|_, _| futures::future::ok(()).boxed());
8425 let (sender, _handle) = mock_packet_planning(transport);
8426 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8427 let _notifications = tokio::spawn(async move {
8428 pin_mut!(new_session_rx);
8429 while let Some(_session) = new_session_rx.next().await {}
8430 });
8431 mgr.start(sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
8432
8433 let offer = |pseudonym| {
8434 (
8435 pseudonym,
8436 StartInitiation {
8437 challenge: MIN_CHALLENGE,
8438 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse().unwrap())),
8439 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
8440 additional_data: into_initiation_additional_data(params.into_additional_data(0)),
8441 },
8442 )
8443 };
8444
8445 let (p1, req1) = offer(HoprPseudonym::random());
8446 mgr.handle_incoming_session_initiation(p1, req1).await?;
8447 assert_eq!(1, mgr.num_active_sessions(), "the first PIX session must be admitted");
8448 assert_eq!(
8449 one_session,
8450 mgr.live_cycle_bytes.load(Ordering::Relaxed),
8451 "and must have charged exactly one session's worth"
8452 );
8453
8454 // The budget is spent, so this one is refused — handled internally as a `SessionError`,
8455 // hence `Ok` with no new slot.
8456 let (p2, req2) = offer(HoprPseudonym::random());
8457 mgr.handle_incoming_session_initiation(p2, req2).await?;
8458 assert_eq!(
8459 1,
8460 mgr.num_active_sessions(),
8461 "a session over the live-cycle budget must not be admitted"
8462 );
8463 assert_eq!(
8464 one_session,
8465 mgr.live_cycle_bytes.load(Ordering::Relaxed),
8466 "and a refused session must not leave a reservation behind"
8467 );
8468
8469 // Closing the admitted session returns its share, and the next request fits again.
8470 assert!(mgr.close_session(&p1));
8471 assert_eq!(
8472 0,
8473 mgr.live_cycle_bytes.load(Ordering::Relaxed),
8474 "closing a session must return its reservation"
8475 );
8476
8477 let (p3, req3) = offer(HoprPseudonym::random());
8478 mgr.handle_incoming_session_initiation(p3, req3).await?;
8479 assert_eq!(
8480 1,
8481 mgr.num_active_sessions(),
8482 "the freed budget must admit the next session"
8483 );
8484
8485 sender.close_channel();
8486 _handle.await??;
8487 Ok(())
8488 }
8489
8490 /// A Session is charged for the dimensions its *peer* offered, not for this node's defaults.
8491 ///
8492 /// The Exit accepts a range of dimensions, and the reconstructor state behind them differs by
8493 /// more than an order of magnitude across it. Charging a flat figure would either refuse small
8494 /// Sessions that fit easily or admit large ones that do not.
8495 #[test]
8496 fn the_live_cycle_reservation_scales_with_the_offered_dimensions() -> anyhow::Result<()> {
8497 let small = PixParams::try_new(1024, 64, 16, LOCAL_PIX_SUITE)?;
8498 let large = PixParams::try_new(8192, 64, 16, LOCAL_PIX_SUITE)?;
8499
8500 assert_eq!(
8501 8 * cycle_budget_for(&small, 1),
8502 cycle_budget_for(&large, 1),
8503 "eight times the polynomials must cost eight times the budget"
8504 );
8505 assert_eq!(
8506 3 * cycle_budget_for(&large, 1),
8507 cycle_budget_for(&large, 3),
8508 "and a batch of three must cost three times a batch of one"
8509 );
8510
8511 // The pipelining factor is in there once, and only once: a batch may have one successor
8512 // outstanding, not one per member.
8513 assert_eq!(
8514 MAX_OVERLAPPING_BATCHES * hopr_protocol_pix::peak_cycle_bytes::<HoprPixSpec>(&large),
8515 cycle_budget_for(&large, 1)
8516 );
8517
8518 // The clamp matches the one `SessionManager::new` applies, so a config that never went
8519 // through it cannot understate its own reservation.
8520 assert_eq!(cycle_budget_for(&large, 1), cycle_budget_for(&large, 0));
8521 assert_eq!(
8522 cycle_budget_for(&large, MAX_SSA_BATCH_SIZE),
8523 cycle_budget_for(&large, usize::MAX)
8524 );
8525
8526 Ok(())
8527 }
8528
8529 /// Runs an admission handler against `mgr`'s request channel for the duration of one test.
8530 ///
8531 /// Mirrors what `hopr-lib` spawns beside the session server, minus the concurrency: these tests
8532 /// drive one initiation at a time.
8533 fn spawn_admission_handler(
8534 handler: impl Fn(SessionAdmissionRequest) -> Result<SessionAdmissionDecision, String> + Send + 'static,
8535 ) -> (SessionAdmissionSink, tokio::task::JoinHandle<()>) {
8536 let (tx, mut rx) =
8537 futures::channel::mpsc::channel::<(SessionAdmissionRequest, crate::types::SessionAdmissionReply)>(4);
8538 let handle = tokio::task::spawn(async move {
8539 while let Some((request, reply)) = rx.next().await {
8540 let _ = reply.send(handler(request));
8541 }
8542 });
8543 (tx, handle)
8544 }
8545
8546 /// What [`started_manager_admitting_small_params`] hands back: the manager, its message sender
8547 /// and the task draining it, the first `SessionError` it replies with, and the incoming-session
8548 /// receiver — which the test must hold, since an admitted Session is published on it and a
8549 /// dropped receiver would turn every success into a send failure.
8550 type AdmissionFixture = (
8551 SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>,
8552 UnboundedSender<(DestinationRouting, ApplicationDataOut)>,
8553 tokio::task::JoinHandle<anyhow::Result<()>>,
8554 oneshot::Receiver<StartErrorType<SessionId>>,
8555 futures::channel::mpsc::Receiver<IncomingSession>,
8556 );
8557
8558 /// A started manager that accepts an Entry offering [`small_pix_params`], with `admission`
8559 /// installed as its session server, so that any refusal is attributable to the decision rather
8560 /// than to the dimensions or to a missing toolbox.
8561 fn started_manager_admitting_small_params(
8562 quota_range: std::ops::RangeInclusive<SsaQuota>,
8563 enforce_pix: bool,
8564 admission: Option<SessionAdmissionSink>,
8565 ) -> anyhow::Result<AdmissionFixture> {
8566 use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
8567
8568 let mgr = SessionManager::new(SessionManagerConfig {
8569 pix_config: IncomingSessionPixConfig {
8570 enforce_pix,
8571 quota_range,
8572 ..Default::default()
8573 },
8574 session_admission_timeout: Duration::from_millis(200),
8575 ..Default::default()
8576 });
8577
8578 let pix_toolbox = pix_toolbox_with_pool(
8579 SsaShareGenerator::new(SsaGeneratorConfig {
8580 polynomials_per_ssa: 2,
8581 threshold: 2,
8582 surplus_shares: TEST_SURPLUS_SHARES,
8583 })
8584 .into(),
8585 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
8586 );
8587
8588 let mut transport = MockMsgSender::new();
8589 let (err_tx, err_rx) = oneshot::channel();
8590 let err_tx = Arc::new(std::sync::Mutex::new(Some(err_tx)));
8591 transport.expect_send_message().returning(move |_, data| {
8592 let err_tx = err_tx.clone();
8593 Box::pin(async move {
8594 if let Ok(HoprStartProtocol::SessionError(err)) =
8595 HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
8596 && let Some(tx) = err_tx.lock().unwrap().take()
8597 {
8598 let _ = tx.send(err);
8599 }
8600 Ok(())
8601 })
8602 });
8603
8604 let (sender, handle) = mock_packet_planning(transport);
8605 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
8606 mgr.start(sender.clone(), new_session_tx, Some(pix_toolbox), admission)?;
8607
8608 Ok((mgr, sender, handle, err_rx, new_session_rx))
8609 }
8610
8611 /// An Entry offering [`small_pix_params`], i.e. an offer the fixture's node accepts.
8612 fn pix_offer() -> StartInitiation<SessionTarget, HoprSessionCapabilities> {
8613 StartInitiation {
8614 challenge: MIN_CHALLENGE,
8615 target: SessionTarget::TcpStream(SealedHost::Plain(
8616 "127.0.0.1:80".parse().expect("test target must parse"),
8617 )),
8618 capabilities: HoprSessionCapabilities(Capability::Segmentation | Capability::UsePIX),
8619 additional_data: small_pix_additional_data(),
8620 }
8621 }
8622
8623 /// The same target from an Entry that offered no incentivization at all.
8624 fn no_pix_offer() -> StartInitiation<SessionTarget, HoprSessionCapabilities> {
8625 StartInitiation {
8626 capabilities: HoprSessionCapabilities(Capability::Segmentation.into()),
8627 additional_data: 0,
8628 ..pix_offer()
8629 }
8630 }
8631
8632 /// The quota an Entry offering [`small_pix_params`] asks for.
8633 fn small_params_quota() -> SsaQuota {
8634 pix_params_to_quota(&small_pix_params())
8635 }
8636
8637 /// A node's configured window is an envelope, not a default: a decision may take less of it, but
8638 /// asking for more of it than was configured — and validated, and preallocated against — leaves
8639 /// the envelope's own bounds in place.
8640 #[test]
8641 fn an_admission_decision_narrows_the_configured_quota_window_and_never_widens_it() {
8642 let cfg = IncomingSessionPixConfig {
8643 quota_range: 100..=200,
8644 ..Default::default()
8645 };
8646
8647 let narrowed = cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(120..=180));
8648 assert_eq!(narrowed.quota_range, 120..=180);
8649
8650 let widened = cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(1..=1000));
8651 assert_eq!(widened.quota_range, 100..=200, "neither bound may leave the envelope");
8652
8653 let one_end = cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(150..=u64::MAX));
8654 assert_eq!(one_end.quota_range, 150..=200, "an open end leaves that bound alone");
8655
8656 let disjoint = cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(500..=600));
8657 assert!(
8658 disjoint.quota_range.is_empty(),
8659 "a rule that does not overlap the envelope admits nothing, rather than silently picking one"
8660 );
8661 }
8662
8663 /// Unlike the quota window, `enforce_pix` is coupled to nothing validated at startup, so a
8664 /// decision simply replaces it — in either direction.
8665 #[test]
8666 fn an_admission_decision_overrides_enforce_pix_in_both_directions() {
8667 let strict = IncomingSessionPixConfig {
8668 enforce_pix: true,
8669 ..Default::default()
8670 };
8671 let lax = IncomingSessionPixConfig {
8672 enforce_pix: false,
8673 ..Default::default()
8674 };
8675
8676 assert!(strict.effective_for(&SessionAdmissionDecision::default()).enforce_pix);
8677 assert!(
8678 !strict
8679 .effective_for(&SessionAdmissionDecision::default().with_enforce_pix(false))
8680 .enforce_pix
8681 );
8682 assert!(!lax.effective_for(&SessionAdmissionDecision::default()).enforce_pix);
8683 assert!(
8684 lax.effective_for(&SessionAdmissionDecision::default().with_enforce_pix(true))
8685 .enforce_pix
8686 );
8687 }
8688
8689 /// Runs an initiation through the same two steps admission does — read the offer off the wire,
8690 /// then judge it against the node's own terms. `None` is a refusal from either.
8691 fn decode_and_check(
8692 mgr: &SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>,
8693 req: &StartInitiation<SessionTarget, HoprSessionCapabilities>,
8694 ) -> Option<(PixParams, usize)> {
8695 let policy = mgr.cfg.pix_config.effective_for(&SessionAdmissionDecision::default());
8696 decode_pix_offer(req)
8697 .ok()
8698 .and_then(|offered| mgr.check_pix_params(req.challenge, offered, &policy))
8699 }
8700
8701 /// The offer must reach the session server decoded, and the capability bits verbatim.
8702 ///
8703 /// Without it the server can only price a target blind: it names a window with no idea whether
8704 /// the peer is anywhere near it, and cannot hold a *dimension* to a floor, since the quota is
8705 /// their product and the same product admits many splits.
8706 #[test_log::test(tokio::test)]
8707 async fn the_admission_request_carries_the_decoded_offer_and_capability_bits() -> anyhow::Result<()> {
8708 let (seen_tx, seen_rx) = oneshot::channel();
8709 let seen_tx = Arc::new(std::sync::Mutex::new(Some(seen_tx)));
8710 let (admission, handler) = spawn_admission_handler(move |request| {
8711 if let Some(tx) = seen_tx.lock().unwrap().take() {
8712 let _ = tx.send((request.capabilities, request.offered));
8713 }
8714 Ok(SessionAdmissionDecision::default())
8715 });
8716 let (mgr, sender, transport, _err_rx, _incoming) =
8717 started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
8718
8719 mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
8720 .await?;
8721
8722 let (capabilities, offered) = seen_rx.await.context("admission was never asked")?;
8723 let offered = offered.context("an offer was made, so one must have been reported")?;
8724
8725 let params = small_pix_params();
8726 assert_eq!(offered.parts_per_ssa, params.polys_per_ssa());
8727 assert_eq!(offered.shares_per_part, params.shares_per_poly());
8728 assert_eq!(
8729 offered.surplus_shares,
8730 params.surplus_shares(),
8731 "the surplus is priced in, so a server judging the quota needs it"
8732 );
8733 assert_eq!(offered.quota_per_ssa, small_params_quota());
8734 assert_eq!(
8735 capabilities,
8736 pix_offer().capabilities.0.bits(),
8737 "the capability bits travel as the peer sent them"
8738 );
8739
8740 sender.close_channel();
8741 transport.await??;
8742 handler.abort();
8743 Ok(())
8744 }
8745
8746 /// A peer offering nothing must be reported as offering nothing, not as offering zero: the two
8747 /// are different answers, and only one of them means "this Session is free if you allow it".
8748 #[test_log::test(tokio::test)]
8749 async fn a_peer_offering_no_incentivization_reports_no_offer() -> anyhow::Result<()> {
8750 let (seen_tx, seen_rx) = oneshot::channel();
8751 let seen_tx = Arc::new(std::sync::Mutex::new(Some(seen_tx)));
8752 let (admission, handler) = spawn_admission_handler(move |request| {
8753 if let Some(tx) = seen_tx.lock().unwrap().take() {
8754 let _ = tx.send(request.offered);
8755 }
8756 Ok(SessionAdmissionDecision::default())
8757 });
8758 let (mgr, sender, transport, _err_rx, _incoming) =
8759 started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
8760
8761 mgr.handle_incoming_session_initiation(HoprPseudonym::random(), no_pix_offer())
8762 .await?;
8763
8764 assert!(
8765 seen_rx.await.context("admission was never asked")?.is_none(),
8766 "no offer must read as no offer"
8767 );
8768
8769 sender.close_channel();
8770 transport.await??;
8771 handler.abort();
8772 Ok(())
8773 }
8774
8775 /// An offer this node cannot read is refused before the session server is troubled with it: no
8776 /// decision could rescue it, and the round trip would be spent to reach the same answer.
8777 #[test_log::test(tokio::test)]
8778 async fn an_unreadable_offer_is_refused_without_asking_the_session_server() -> anyhow::Result<()> {
8779 let asked = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8780 let asked_by_handler = asked.clone();
8781 let (admission, handler) = spawn_admission_handler(move |_| {
8782 asked_by_handler.fetch_add(1, Ordering::Relaxed);
8783 Ok(SessionAdmissionDecision::default())
8784 });
8785 let (mgr, sender, transport, err_rx, _incoming) =
8786 started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
8787
8788 // Zero polynomials is outside the protocol range, so unpacking refuses it outright.
8789 let unreadable = StartInitiation {
8790 additional_data: into_initiation_additional_data(((LOCAL_PIX_SUITE as u64) << 62) | (128u64 << 40)),
8791 ..pix_offer()
8792 };
8793 mgr.handle_incoming_session_initiation(HoprPseudonym::random(), unreadable)
8794 .await?;
8795
8796 let err = err_rx.await.context("send_message was never called")?;
8797 assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
8798 assert_eq!(
8799 asked.load(Ordering::Relaxed),
8800 0,
8801 "an offer that cannot be read must not cost an admission round trip"
8802 );
8803 assert_eq!(mgr.num_active_sessions(), 0);
8804
8805 sender.close_channel();
8806 transport.await??;
8807 handler.abort();
8808 Ok(())
8809 }
8810
8811 /// The feature itself: one Exit, one Entry offering the same thing, two targets, two answers.
8812 ///
8813 /// Every other test here fixes the decision and varies the offer; this one fixes the offer and
8814 /// varies the target, which is the only thing #8376 actually asks for.
8815 #[test_log::test(tokio::test)]
8816 async fn two_targets_on_one_exit_are_admitted_on_different_terms() -> anyhow::Result<()> {
8817 let free_target = SessionTarget::TcpStream(SealedHost::Plain("10.0.0.1:80".parse()?));
8818 let paid_target = SessionTarget::TcpStream(SealedHost::Plain("10.0.0.2:80".parse()?));
8819
8820 let free_for = free_target.clone();
8821 let (admission, handler) = spawn_admission_handler(move |request| {
8822 Ok(if request.target == free_for {
8823 SessionAdmissionDecision::default().with_enforce_pix(false)
8824 } else {
8825 SessionAdmissionDecision::default().with_enforce_pix(true)
8826 })
8827 });
8828
8829 // The node demands PIX; only the rule for `free_target` waives it.
8830 let (mgr, sender, transport, err_rx, _incoming) =
8831 started_manager_admitting_small_params(1..=small_params_quota(), true, Some(admission))?;
8832
8833 // Same Entry offer both times — no PIX at all.
8834 mgr.handle_incoming_session_initiation(
8835 HoprPseudonym::random(),
8836 StartInitiation {
8837 target: free_target,
8838 ..no_pix_offer()
8839 },
8840 )
8841 .await?;
8842 assert_eq!(mgr.num_active_sessions(), 1, "the waived target must be served");
8843
8844 mgr.handle_incoming_session_initiation(
8845 HoprPseudonym::random(),
8846 StartInitiation {
8847 target: paid_target,
8848 challenge: MIN_CHALLENGE + 1,
8849 ..no_pix_offer()
8850 },
8851 )
8852 .await?;
8853 assert_eq!(
8854 mgr.num_active_sessions(),
8855 1,
8856 "the second target must be refused, leaving only the first session"
8857 );
8858
8859 let err = err_rx.await.context("send_message was never called")?;
8860 assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
8861 assert_eq!(
8862 err.identifier,
8863 ErrorIdentifier::Challenge(MIN_CHALLENGE + 1),
8864 "the refusal must name the second initiation, not the admitted first"
8865 );
8866
8867 sender.close_channel();
8868 transport.await??;
8869 handler.abort();
8870 Ok(())
8871 }
8872
8873 /// A queue with no reader is the third transient failure, beside the timeout and the dropped
8874 /// reply. It is `Busy` for the same reason: nothing about the target was decided.
8875 ///
8876 /// Reachable only because every request goes through one shared sender — sending through a
8877 /// fresh clone would hand each call its own guaranteed slot and the queue would never be full.
8878 #[test_log::test(tokio::test)]
8879 async fn a_full_admission_queue_refuses_the_session_as_busy() -> anyhow::Result<()> {
8880 // No receiver task at all, so nothing is ever drained. `_rx` is held rather than dropped:
8881 // a closed channel is a different failure from a full one.
8882 let (admission, _rx) =
8883 futures::channel::mpsc::channel::<(SessionAdmissionRequest, crate::types::SessionAdmissionReply)>(0);
8884
8885 let (mgr, sender, transport, err_rx, _incoming) =
8886 started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
8887
8888 // The first request takes the sender's guaranteed slot; the second finds the queue full.
8889 for challenge in [MIN_CHALLENGE, MIN_CHALLENGE + 1] {
8890 mgr.handle_incoming_session_initiation(
8891 HoprPseudonym::random(),
8892 StartInitiation {
8893 challenge,
8894 ..pix_offer()
8895 },
8896 )
8897 .await?;
8898 }
8899
8900 let err = err_rx.await.context("send_message was never called")?;
8901 assert_eq!(err.reason, StartErrorReason::Busy);
8902 assert_eq!(mgr.num_active_sessions(), 0);
8903
8904 sender.close_channel();
8905 transport.await??;
8906 Ok(())
8907 }
8908
8909 /// The empty-intersection case is a misconfiguration; this is the ordinary one. The narrowed
8910 /// window is a real range that simply sits above what the Entry offered, and the refusal must
8911 /// look the same.
8912 #[test_log::test(tokio::test)]
8913 async fn a_narrowed_window_that_excludes_the_offer_refuses_without_being_empty() -> anyhow::Result<()> {
8914 let quota = small_params_quota();
8915 let (admission, handler) = spawn_admission_handler(move |_| {
8916 Ok(SessionAdmissionDecision::default().with_pix_quota_range(quota + 1..=2 * quota))
8917 });
8918 let (mgr, sender, transport, err_rx, _incoming) =
8919 started_manager_admitting_small_params(1..=2 * quota, false, Some(admission))?;
8920
8921 // The intersection is `quota+1..=2*quota` — non-empty, and above the offer.
8922 let effective = mgr
8923 .cfg
8924 .pix_config
8925 .effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(quota + 1..=2 * quota));
8926 assert!(!effective.quota_range.is_empty(), "this case must not be the empty one");
8927
8928 mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
8929 .await?;
8930
8931 let err = err_rx.await.context("send_message was never called")?;
8932 assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
8933 assert_eq!(mgr.num_active_sessions(), 0);
8934
8935 sender.close_channel();
8936 transport.await??;
8937 handler.abort();
8938 Ok(())
8939 }
8940
8941 /// Narrowing does not only accept or reject — with dynamic batching it moves the batch the Exit
8942 /// asks for, which is what the Entry pays per deposit round trip.
8943 #[test]
8944 fn a_narrowed_window_can_change_the_selected_batch_size() {
8945 let cfg = IncomingSessionPixConfig {
8946 quota_range: 100..=400,
8947 supervision: SupervisorConfig {
8948 ssas_per_request: 4,
8949 allow_dynamic_ssa_batches: true,
8950 ..Default::default()
8951 },
8952 ..Default::default()
8953 };
8954
8955 // The node's own window accepts a single SSA of 100.
8956 assert_eq!(
8957 cfg.effective_for(&SessionAdmissionDecision::default())
8958 .ssa_batch_size_for_quota(100),
8959 Some(1)
8960 );
8961
8962 // Narrowed to a floor of 250, the smallest batch that reaches it is three.
8963 assert_eq!(
8964 cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(250..=400))
8965 .ssa_batch_size_for_quota(100),
8966 Some(3)
8967 );
8968
8969 // Narrowed to a window every multiple of the offer jumps over — three total 300 and four
8970 // total 400, with nothing in between — so no batch fits and the Session is refused.
8971 assert_eq!(
8972 cfg.effective_for(&SessionAdmissionDecision::default().with_pix_quota_range(310..=390))
8973 .ssa_batch_size_for_quota(100),
8974 None
8975 );
8976 }
8977
8978 /// Admission is an Exit-side question. An Entry establishing a Session decided its own target,
8979 /// so putting it to the local session server would be asking a node to vet itself.
8980 #[test_log::test(tokio::test)]
8981 async fn an_outgoing_session_is_never_put_to_the_admission_hook() -> anyhow::Result<()> {
8982 let asked = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8983 let asked_by_handler = asked.clone();
8984 let (admission, handler) = spawn_admission_handler(move |_| {
8985 asked_by_handler.fetch_add(1, Ordering::Relaxed);
8986 Ok(SessionAdmissionDecision::default())
8987 });
8988
8989 let mgr = SessionManager::new(SessionManagerConfig {
8990 initiation_timeout_base: Duration::from_millis(50),
8991 ..Default::default()
8992 });
8993
8994 let mut transport = MockMsgSender::new();
8995 transport
8996 .expect_send_message()
8997 .returning(|_, _| Box::pin(async { Ok(()) }));
8998 let (sender, transport_handle) = mock_packet_planning(transport);
8999 let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(1);
9000 mgr.start(sender.clone(), new_session_tx, None, Some(admission))?;
9001
9002 // No peer answers, so this times out — which is fine: the assertion is about what was asked
9003 // before the attempt died, not about it succeeding.
9004 let _ = mgr
9005 .new_session(
9006 (&hopr_api::types::crypto::keypairs::ChainKeypair::random()).into(),
9007 SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9008 SessionClientConfig {
9009 pseudonym: Some(HoprPseudonym::random()),
9010 capabilities: Capability::Segmentation.into(),
9011 surb_management: None,
9012 ..Default::default()
9013 },
9014 )
9015 .await;
9016
9017 assert_eq!(
9018 asked.load(Ordering::Relaxed),
9019 0,
9020 "an outgoing session must not consult the admission hook"
9021 );
9022
9023 sender.close_channel();
9024 transport_handle.await??;
9025 handler.abort();
9026 Ok(())
9027 }
9028
9029 /// The issue's first case: a target served without payment on a node that otherwise demands it.
9030 #[test_log::test(tokio::test)]
9031 async fn a_decision_waiving_pix_admits_a_client_that_offered_none() -> anyhow::Result<()> {
9032 let (admission, handler) =
9033 spawn_admission_handler(|_| Ok(SessionAdmissionDecision::default().with_enforce_pix(false)));
9034 let (mgr, sender, transport, err_rx, _incoming) =
9035 started_manager_admitting_small_params(1..=small_params_quota(), true, Some(admission))?;
9036
9037 mgr.handle_incoming_session_initiation(HoprPseudonym::random(), no_pix_offer())
9038 .await?;
9039
9040 assert_eq!(
9041 mgr.num_active_sessions(),
9042 1,
9043 "a waived target must be admitted even though the node enforces PIX"
9044 );
9045
9046 sender.close_channel();
9047 transport.await??;
9048 assert!(
9049 err_rx.await.is_err(),
9050 "no SessionError may be sent for an admitted session"
9051 );
9052 handler.abort();
9053 Ok(())
9054 }
9055
9056 /// And its mirror: a target that must pay on a node that otherwise does not ask.
9057 #[test_log::test(tokio::test)]
9058 async fn a_decision_demanding_pix_refuses_a_client_that_offered_none() -> anyhow::Result<()> {
9059 let (admission, handler) =
9060 spawn_admission_handler(|_| Ok(SessionAdmissionDecision::default().with_enforce_pix(true)));
9061 let (mgr, sender, transport, err_rx, _incoming) =
9062 started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9063
9064 mgr.handle_incoming_session_initiation(HoprPseudonym::random(), no_pix_offer())
9065 .await?;
9066
9067 let err = err_rx.await.context("send_message was never called")?;
9068 assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
9069 assert_eq!(mgr.num_active_sessions(), 0, "no slot may be created for a refusal");
9070
9071 sender.close_channel();
9072 transport.await??;
9073 handler.abort();
9074 Ok(())
9075 }
9076
9077 /// A window narrowed past the client's offer refuses it even though the node's own window would
9078 /// have accepted it, which is the whole point of parameterizing admission by target.
9079 #[test_log::test(tokio::test)]
9080 async fn a_decision_narrowing_the_quota_past_the_offer_refuses_it() -> anyhow::Result<()> {
9081 let quota = small_params_quota();
9082 let (admission, handler) = spawn_admission_handler(move |_| {
9083 Ok(SessionAdmissionDecision::default().with_pix_quota_range(quota + 1..=u64::MAX))
9084 });
9085 let (mgr, sender, transport, err_rx, _incoming) =
9086 started_manager_admitting_small_params(1..=quota, false, Some(admission))?;
9087
9088 mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9089 .await?;
9090
9091 let err = err_rx.await.context("send_message was never called")?;
9092 assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
9093 assert_eq!(mgr.num_active_sessions(), 0);
9094
9095 sender.close_channel();
9096 transport.await??;
9097 handler.abort();
9098 Ok(())
9099 }
9100
9101 /// The same offer, and the same node, admitted once the decision leaves the window alone —
9102 /// which is what makes the refusal above attributable to the narrowing and nothing else.
9103 #[test_log::test(tokio::test)]
9104 async fn the_same_offer_is_admitted_when_the_decision_leaves_the_window_alone() -> anyhow::Result<()> {
9105 let quota = small_params_quota();
9106 let (admission, handler) = spawn_admission_handler(|_| Ok(SessionAdmissionDecision::default()));
9107 let (mgr, sender, transport, err_rx, _incoming) =
9108 started_manager_admitting_small_params(1..=quota, false, Some(admission))?;
9109
9110 mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9111 .await?;
9112
9113 assert_eq!(mgr.num_active_sessions(), 1);
9114
9115 sender.close_channel();
9116 transport.await??;
9117 assert!(
9118 err_rx.await.is_err(),
9119 "no SessionError may be sent for an admitted session"
9120 );
9121 handler.abort();
9122 Ok(())
9123 }
9124
9125 /// A refusal is permanent and says so, so the peer does not retry a target this Exit will not
9126 /// serve however the request is phrased.
9127 #[test_log::test(tokio::test)]
9128 async fn a_refused_admission_is_reported_as_a_target_that_is_not_admitted() -> anyhow::Result<()> {
9129 let (admission, handler) = spawn_admission_handler(|_| Err("this exit does not serve that".into()));
9130 let (mgr, sender, transport, err_rx, _incoming) =
9131 started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9132
9133 mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9134 .await?;
9135
9136 let err = err_rx.await.context("send_message was never called")?;
9137 assert_eq!(err.reason, StartErrorReason::TargetNotAdmitted);
9138 assert_eq!(err.identifier, ErrorIdentifier::Challenge(MIN_CHALLENGE));
9139 assert_eq!(mgr.num_active_sessions(), 0, "no slot may be created for a refusal");
9140
9141 sender.close_channel();
9142 transport.await??;
9143 handler.abort();
9144 Ok(())
9145 }
9146
9147 /// A server that does not answer must not become a server that admits everything: the Session is
9148 /// refused, and as `Busy` rather than `TargetNotAdmitted`, since nothing about the target was
9149 /// decided and a retry may well succeed.
9150 #[test_log::test(tokio::test)]
9151 async fn an_admission_that_never_answers_refuses_the_session_as_busy() -> anyhow::Result<()> {
9152 // Holds every request without replying, which is what a wedged session server looks like.
9153 let (admission, mut rx) =
9154 futures::channel::mpsc::channel::<(SessionAdmissionRequest, crate::types::SessionAdmissionReply)>(4);
9155 let handler = tokio::task::spawn(async move {
9156 let mut held = Vec::new();
9157 while let Some(request) = rx.next().await {
9158 held.push(request);
9159 }
9160 });
9161
9162 let (mgr, sender, transport, err_rx, _incoming) =
9163 started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9164
9165 mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9166 .await?;
9167
9168 let err = err_rx.await.context("send_message was never called")?;
9169 assert_eq!(err.reason, StartErrorReason::Busy);
9170 assert_eq!(mgr.num_active_sessions(), 0);
9171
9172 sender.close_channel();
9173 transport.await??;
9174 handler.abort();
9175 Ok(())
9176 }
9177
9178 /// Same outcome when the server drops the reply instead of answering: a bug in it is still not a
9179 /// decision, and must not be read as one.
9180 #[test_log::test(tokio::test)]
9181 async fn an_admission_whose_reply_is_dropped_refuses_the_session_as_busy() -> anyhow::Result<()> {
9182 let (admission, mut rx) =
9183 futures::channel::mpsc::channel::<(SessionAdmissionRequest, crate::types::SessionAdmissionReply)>(4);
9184 let handler = tokio::task::spawn(async move {
9185 while let Some((_request, reply)) = rx.next().await {
9186 drop(reply);
9187 }
9188 });
9189
9190 let (mgr, sender, transport, err_rx, _incoming) =
9191 started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9192
9193 mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9194 .await?;
9195
9196 let err = err_rx.await.context("send_message was never called")?;
9197 assert_eq!(err.reason, StartErrorReason::Busy);
9198 assert_eq!(mgr.num_active_sessions(), 0);
9199
9200 sender.close_channel();
9201 transport.await??;
9202 handler.abort();
9203 Ok(())
9204 }
9205
9206 /// A node that installed no session server has nobody to ask, so it must behave exactly as it
9207 /// did before this hook existed rather than failing closed on an absent decision.
9208 #[test_log::test(tokio::test)]
9209 async fn a_manager_without_an_admission_sink_admits_on_its_own_terms() -> anyhow::Result<()> {
9210 let (mgr, sender, transport, err_rx, _incoming) =
9211 started_manager_admitting_small_params(1..=small_params_quota(), false, None)?;
9212
9213 mgr.handle_incoming_session_initiation(HoprPseudonym::random(), pix_offer())
9214 .await?;
9215
9216 assert_eq!(
9217 mgr.num_active_sessions(),
9218 1,
9219 "an offer the node's own policy accepts must be admitted with no session server present"
9220 );
9221
9222 sender.close_channel();
9223 transport.await??;
9224 assert!(
9225 err_rx.await.is_err(),
9226 "no SessionError may be sent for an admitted session"
9227 );
9228 Ok(())
9229 }
9230
9231 /// The request the session server is handed must name the target it is being asked about, or the
9232 /// hook cannot do the one job it exists for.
9233 #[test_log::test(tokio::test)]
9234 async fn the_admission_request_carries_the_session_target() -> anyhow::Result<()> {
9235 let (seen_tx, seen_rx) = oneshot::channel();
9236 let seen_tx = Arc::new(std::sync::Mutex::new(Some(seen_tx)));
9237 let (admission, handler) = spawn_admission_handler(move |request| {
9238 if let Some(tx) = seen_tx.lock().unwrap().take() {
9239 let _ = tx.send((request.session_id, request.target.clone()));
9240 }
9241 Ok(SessionAdmissionDecision::default())
9242 });
9243 let (mgr, sender, transport, _err_rx, _incoming) =
9244 started_manager_admitting_small_params(1..=small_params_quota(), false, Some(admission))?;
9245
9246 let pseudonym = HoprPseudonym::random();
9247 let offer = pix_offer();
9248 let expected_target = offer.target.clone();
9249 mgr.handle_incoming_session_initiation(pseudonym, offer).await?;
9250
9251 let (seen_id, seen_target) = seen_rx.await.context("admission was never asked")?;
9252 assert_eq!(seen_target, expected_target);
9253 assert_eq!(seen_id, pseudonym, "the request must name the session it would become");
9254
9255 sender.close_channel();
9256 transport.await??;
9257 handler.abort();
9258 Ok(())
9259 }
9260
9261 /// The wait is spent inside the peer's own initiation timeout, so a configuration that outlasts
9262 /// it is normalized rather than left to expire after the peer has already given up.
9263 #[test]
9264 fn the_admission_timeout_is_clamped_below_the_initiation_budget() {
9265 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9266 SessionManager::new(SessionManagerConfig {
9267 initiation_timeout_base: Duration::from_millis(100),
9268 session_admission_timeout: Duration::from_secs(3600),
9269 ..Default::default()
9270 });
9271
9272 assert_eq!(
9273 mgr.cfg.session_admission_timeout,
9274 initiation_timeout_max_one_way(Duration::from_millis(100), RoutingOptions::MAX_INTERMEDIATE_HOPS)
9275 );
9276 }
9277
9278 /// Dynamic admission chooses the least expensive batch that brings the Entry's total offer into
9279 /// the accepted range. A cap is still a cap: it may leave the offer unsatisfiable, and checked
9280 /// multiplication must make an overflowing candidate a refusal rather than a wrapped match.
9281 #[test]
9282 fn dynamic_ssa_batches_choose_the_smallest_satisfying_batch() {
9283 // Through `effective_for` with an empty decision, which is the no-session-server path and
9284 // so yields exactly the node's configured policy.
9285 let config = |quota_range, ssas_per_request| {
9286 IncomingSessionPixConfig {
9287 quota_range,
9288 supervision: SupervisorConfig {
9289 ssas_per_request,
9290 allow_dynamic_ssa_batches: true,
9291 ..Default::default()
9292 },
9293 ..Default::default()
9294 }
9295 .effective_for(&SessionAdmissionDecision::default())
9296 };
9297
9298 assert_eq!(Some(1), config(100..=350, 4).ssa_batch_size_for_quota(100));
9299 assert_eq!(Some(3), config(250..=350, 4).ssa_batch_size_for_quota(100));
9300 assert_eq!(None, config(250..=350, 2).ssa_batch_size_for_quota(100));
9301 assert_eq!(
9302 None,
9303 config(150..=175, 4).ssa_batch_size_for_quota(100),
9304 "the first multiple jumps over the range and every later one is larger"
9305 );
9306 assert_eq!(None, config(100..=350, 4).ssa_batch_size_for_quota(400));
9307 assert_eq!(
9308 None,
9309 config(u64::MAX..=u64::MAX, 2).ssa_batch_size_for_quota(u64::MAX / 2 + 1),
9310 "an overflowing batch quota must not wrap into the accepted range"
9311 );
9312 }
9313
9314 /// Disabling dynamic admission restores both halves of the old contract: only the per-SSA quota
9315 /// is compared with `quota_range`, and an accepted offer uses the configured batch exactly.
9316 #[test]
9317 fn disabling_dynamic_ssa_batches_preserves_fixed_batch_admission() {
9318 let fixed = |quota_range| {
9319 IncomingSessionPixConfig {
9320 quota_range,
9321 supervision: SupervisorConfig {
9322 ssas_per_request: 3,
9323 allow_dynamic_ssa_batches: false,
9324 ..Default::default()
9325 },
9326 ..Default::default()
9327 }
9328 .effective_for(&SessionAdmissionDecision::default())
9329 };
9330
9331 assert_eq!(
9332 None,
9333 fixed(250..=350).ssa_batch_size_for_quota(100),
9334 "fixed mode must not rescue a sub-range SSA even when three of them total 300"
9335 );
9336 assert_eq!(
9337 Some(3),
9338 fixed(100..=100).ssa_batch_size_for_quota(100),
9339 "an individually accepted SSA must retain the configured exact batch"
9340 );
9341 }
9342
9343 /// Admission reserves against the batch it selected for this Session, not the configured dynamic
9344 /// ceiling. Otherwise a ceiling of nine would charge every already-satisfactory one-SSA offer as
9345 /// nine and silently divide the number of Sessions the Exit can admit.
9346 #[test]
9347 fn live_cycle_reservation_uses_the_selected_batch_size() -> anyhow::Result<()> {
9348 let params = small_pix_params();
9349 let selected_batch = 2;
9350 let expected = cycle_budget_for(¶ms, selected_batch);
9351 let manager: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9352 SessionManager::new(SessionManagerConfig {
9353 pix_config: IncomingSessionPixConfig {
9354 max_live_cycle_bytes: expected,
9355 supervision: SupervisorConfig {
9356 ssas_per_request: 4,
9357 ..Default::default()
9358 },
9359 ..Default::default()
9360 },
9361 ..Default::default()
9362 });
9363
9364 let reservation = manager
9365 .reserve_cycle_budget(¶ms, selected_batch)
9366 .context("the selected batch should fit exactly")?;
9367 assert_eq!(expected, manager.live_cycle_bytes.load(Ordering::Relaxed));
9368 drop(reservation);
9369 assert_eq!(0, manager.live_cycle_bytes.load(Ordering::Relaxed));
9370
9371 Ok(())
9372 }
9373
9374 /// A non-PIX Session reserves nothing.
9375 ///
9376 /// `check_pix_params` hands back nominal parameters for a peer that offered no PIX at all, and
9377 /// charging on those would bill every plain Session for reconstructor state that will never
9378 /// exist — silently capping a node that does not run PIX.
9379 #[test_log::test(tokio::test)]
9380 async fn a_non_pix_session_does_not_touch_the_live_cycle_budget() -> anyhow::Result<()> {
9381 use hopr_utils::network_types::prelude::SealedHost;
9382
9383 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9384 SessionManager::new(SessionManagerConfig {
9385 pix_config: IncomingSessionPixConfig {
9386 // Not enough for any PIX session at all; a plain one must be unaffected.
9387 max_live_cycle_bytes: 1,
9388 ..Default::default()
9389 },
9390 ..Default::default()
9391 });
9392
9393 let mut transport = MockMsgSender::new();
9394 transport
9395 .expect_send_message()
9396 .returning(|_, _| futures::future::ok(()).boxed());
9397 let (sender, _handle) = mock_packet_planning(transport);
9398 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
9399 let _notifications = tokio::spawn(async move {
9400 pin_mut!(new_session_rx);
9401 while let Some(_session) = new_session_rx.next().await {}
9402 });
9403 mgr.start(sender.clone(), new_session_tx, None, None)?;
9404
9405 mgr.handle_incoming_session_initiation(
9406 HoprPseudonym::random(),
9407 StartInitiation {
9408 challenge: MIN_CHALLENGE,
9409 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9410 capabilities: HoprSessionCapabilities::empty(),
9411 additional_data: 0,
9412 },
9413 )
9414 .await?;
9415
9416 assert_eq!(1, mgr.num_active_sessions());
9417 assert_eq!(0, mgr.live_cycle_bytes.load(Ordering::Relaxed));
9418
9419 sender.close_channel();
9420 _handle.await??;
9421 Ok(())
9422 }
9423
9424 /// Verifies the early `TooManySessions` return at the top of `new_session` (line 767).
9425 /// Unlike `session_manager_should_reject_new_session_when_max_sessions_reached`, which fills
9426 /// incoming slots and hits the slot-guard path at line 957, this test fills all `maximum_sessions`
9427 /// slots so that the `if self.cfg.maximum_sessions <= self.sessions.entry_count()` check fires
9428 /// before any message is sent.
9429 #[test_log::test(tokio::test)]
9430 async fn new_session_returns_too_many_sessions_when_cache_is_full() -> anyhow::Result<()> {
9431 use hopr_utils::network_types::prelude::SealedHost;
9432
9433 let cfg = SessionManagerConfig {
9434 maximum_sessions: 2,
9435 idle_timeout: Duration::from_secs(3600),
9436 ..Default::default()
9437 };
9438 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> = SessionManager::new(cfg);
9439
9440 let mut transport = MockMsgSender::new();
9441 // Two incoming sessions: first sends SessionEstablished, second sends SessionError (no slots).
9442 transport
9443 .expect_send_message()
9444 .times(2)
9445 .returning(|_, _| futures::future::ok(()).boxed());
9446
9447 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
9448 let _notifications = tokio::spawn(async move {
9449 pin_mut!(new_session_rx);
9450 while let Some(_session) = new_session_rx.next().await {}
9451 });
9452 let (sender, _handle) = mock_packet_planning(transport);
9453 mgr.start(sender.clone(), new_session_tx, None, None)?;
9454 assert!(mgr.is_started());
9455
9456 // Fill the cache with two incoming sessions (Exits).
9457 for i in 0..2 {
9458 let pseudonym = HoprPseudonym::random();
9459 mgr.handle_incoming_session_initiation(
9460 pseudonym,
9461 StartInitiation {
9462 challenge: MIN_CHALLENGE + i as u64,
9463 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9464 capabilities: HoprSessionCapabilities::empty(),
9465 additional_data: 0,
9466 },
9467 )
9468 .await?;
9469 }
9470 assert_eq!(mgr.active_sessions().len(), 2);
9471 assert_eq!(mgr.num_active_sessions(), 2);
9472
9473 // Third outgoing call hits the early return before sending anything.
9474 let result = mgr
9475 .new_session(
9476 Address::from(&ChainKeypair::random()),
9477 SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9478 SessionClientConfig {
9479 surb_management: None,
9480 ..Default::default()
9481 },
9482 )
9483 .await;
9484
9485 assert!(result.is_err());
9486 assert!(matches!(
9487 result.unwrap_err(),
9488 TransportSessionError::Manager(SessionManagerError::TooManySessions)
9489 ));
9490
9491 sender.close_channel();
9492 _handle.await??;
9493 Ok(())
9494 }
9495
9496 /// Verifies that `session_initiations` is cleaned up when `new_session` fails to
9497 /// send the StartSession message (e.g. the underlying channel is closed).
9498 #[test_log::test(tokio::test)]
9499 async fn new_session_removes_challenge_on_send_failure() -> anyhow::Result<()> {
9500 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9501 SessionManager::new(Default::default());
9502
9503 // Create a channel whose receiver is dropped immediately. When the mock
9504 // transport tries to `send` over this channel the call will return an error,
9505 // which propagates up through `send_via_msg_sender` as
9506 // `TransportSessionError::packet_sending`.
9507 let (tx, rx) = futures::channel::mpsc::unbounded();
9508 drop(rx);
9509
9510 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
9511 let _notifications = tokio::spawn(async move {
9512 pin_mut!(new_session_rx);
9513 while let Some(_session) = new_session_rx.next().await {}
9514 });
9515 mgr.start(tx, new_session_tx, None, None)?;
9516 assert!(mgr.is_started());
9517
9518 // Verify that sending fails because the receiver is gone.
9519 let result = mgr
9520 .new_session(
9521 Address::from(&ChainKeypair::random()),
9522 SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9523 SessionClientConfig {
9524 surb_management: None,
9525 ..Default::default()
9526 },
9527 )
9528 .await;
9529
9530 assert!(result.is_err());
9531 assert_eq!(mgr.num_active_sessions(), 0);
9532 // The challenge must have been removed from `session_initiations` even
9533 // though the send failed.
9534 assert_eq!(
9535 mgr.session_initiations.entry_count(),
9536 0,
9537 "session_initiations was not cleaned up after send failure"
9538 );
9539
9540 Ok(())
9541 }
9542
9543 /// Verifies that `session_initiations` is cleaned up when the session initiation
9544 /// times out waiting for a response (neither `SessionEstablished` nor
9545 /// `SessionError` arrives).
9546 #[test_log::test(tokio::test)]
9547 async fn new_session_removes_challenge_on_timeout() -> anyhow::Result<()> {
9548 let cfg = SessionManagerConfig {
9549 initiation_timeout_base: Duration::from_millis(100),
9550 ..Default::default()
9551 };
9552
9553 let alice_mgr = SessionManager::new(cfg);
9554 let bob_mgr = SessionManager::new(Default::default());
9555
9556 let bob_peer: Address = (&ChainKeypair::random()).into();
9557
9558 let mut alice_transport = MockMsgSender::new();
9559 let bob_transport = MockMsgSender::new();
9560
9561 // Alice sends the StartSession message; Bob never responds.
9562 alice_transport
9563 .expect_send_message()
9564 .once()
9565 .returning(|_, _| futures::future::ok(()).boxed());
9566
9567 let (alice_sender, _alice_handle) = mock_packet_planning(alice_transport);
9568 let (new_session_tx_alice, _) = futures::channel::mpsc::channel(1024);
9569 alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?;
9570 assert!(alice_mgr.is_started());
9571
9572 let (bob_sender, _bob_handle) = mock_packet_planning(bob_transport);
9573 let (new_session_tx_bob, _) = futures::channel::mpsc::channel(1024);
9574 bob_mgr.start(bob_sender.clone(), new_session_tx_bob, None, None)?;
9575 assert!(bob_mgr.is_started());
9576
9577 // Record how many entries are in `session_initiations` before the call.
9578 assert_eq!(alice_mgr.session_initiations.entry_count(), 0);
9579
9580 let result = alice_mgr
9581 .new_session(
9582 bob_peer,
9583 SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9584 SessionClientConfig {
9585 capabilities: None.into(),
9586 pseudonym: None,
9587 surb_management: None,
9588 ..Default::default()
9589 },
9590 )
9591 .await;
9592
9593 assert!(matches!(result, Err(TransportSessionError::Timeout)));
9594 assert_eq!(alice_mgr.num_active_sessions(), 0);
9595 // The pending challenge must have been removed from `session_initiations`
9596 // after the timeout error propagated.
9597 assert_eq!(
9598 alice_mgr.session_initiations.entry_count(),
9599 0,
9600 "session_initiations was not cleaned up after timeout"
9601 );
9602
9603 Ok(())
9604 }
9605
9606 /// Convenience: a session data packet carrying `payload`.
9607 #[cfg(test)]
9608 fn session_data_packet(payload: &[u8]) -> anyhow::Result<ApplicationDataIn> {
9609 Ok(ApplicationDataIn {
9610 data: ApplicationData::new(SESSION_APPLICATION_TAG, payload)?,
9611 packet_info: Default::default(),
9612 })
9613 }
9614
9615 /// The manager sink type used by the dispatch drop tests, which never open a session and so
9616 /// never exercise the sink — its concrete type only needs to be nameable.
9617 #[cfg(test)]
9618 type TestManager =
9619 SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>>;
9620
9621 // Test B (fully deregistered): a data packet for a session id absent from the registry is a
9622 // benign, quiet drop counted as UnknownData — NOT an error, and NOT one ERROR log per packet.
9623 // Under the old behaviour this returned `Err(UnknownData)` and the caller logged it at ERROR,
9624 // which is what produced the per-packet spam during the incident.
9625 #[test_log::test(tokio::test)]
9626 async fn dispatching_to_an_unregistered_session_should_be_a_quiet_counted_drop() -> anyhow::Result<()> {
9627 let mgr: TestManager = SessionManager::new(Default::default());
9628
9629 let pseudonym = HoprPseudonym::random();
9630 let before = crate::counters::session_unknown_data_drop_count();
9631
9632 // N packets → N drops, each an Ok(Dropped(Unregistered)), no panic, no unbounded error.
9633 const N: usize = 5;
9634 for _ in 0..N {
9635 let result = mgr.dispatch_message(pseudonym, session_data_packet(b"test data")?);
9636 assert!(
9637 matches!(result, Ok(DispatchResult::Dropped(DropReason::Unregistered))),
9638 "unregistered-session packet must be a benign Dropped(Unregistered), got {result:?}"
9639 );
9640 }
9641
9642 // Counter advanced by at least N (>= rather than == because it is a process-global atomic
9643 // other tests in this binary may also touch).
9644 assert!(crate::counters::session_unknown_data_drop_count() >= before + N);
9645
9646 Ok(())
9647 }
9648
9649 // Test A (primary regression): a data packet for a session whose data sink has been dropped
9650 // while the slot is still registered must be dropped quietly as SinkClosed — NOT an error.
9651 // This is the "sending on a disconnected channel" ERROR from the incident. The bad-behaviour
9652 // assertion is the silent-vs-loud distinction: the result is Ok(Dropped(SinkClosed)), not Err,
9653 // and specifically not SinkFull (which would misattribute a teardown as backpressure).
9654 #[test_log::test(tokio::test)]
9655 async fn dispatching_to_a_session_whose_sink_closed_should_be_a_quiet_counted_drop() -> anyhow::Result<()> {
9656 let mgr: TestManager = SessionManager::new(Default::default());
9657
9658 // `pre_populate_session` registers the slot and immediately drops its receiver, so the
9659 // sink is closed while the slot is still present — exactly the incident's race.
9660 let pseudonym = HoprPseudonym::random();
9661 mgr.pre_populate_session(pseudonym, DestinationRouting::Return(pseudonym.into()));
9662
9663 let before = crate::counters::session_inbox_closed_drop_count();
9664 let result = mgr.dispatch_message(pseudonym, session_data_packet(b"after teardown")?);
9665
9666 assert!(
9667 matches!(result, Ok(DispatchResult::Dropped(DropReason::SinkClosed))),
9668 "closed-sink packet must be a benign Dropped(SinkClosed), not an error or SinkFull, got {result:?}"
9669 );
9670 assert!(crate::counters::session_inbox_closed_drop_count() > before);
9671
9672 Ok(())
9673 }
9674
9675 // Genuine backpressure (inbox full, reader alive but not keeping up) is distinct from a
9676 // teardown: it is Dropped(SinkFull) and counted in SESSION_INBOX_DROPS, not the closed counter.
9677 #[test_log::test(tokio::test)]
9678 async fn dispatching_to_a_full_session_inbox_should_be_a_backpressure_drop() -> anyhow::Result<()> {
9679 // Capacity 1 so a single unread packet fills the inbox deterministically.
9680 let cfg = SessionManagerConfig {
9681 session_forward_capacity: 1,
9682 ..Default::default()
9683 };
9684 let mgr: TestManager = SessionManager::new(cfg);
9685
9686 // Keep the receiver alive but never read it, so the channel is open but saturates.
9687 let pseudonym = HoprPseudonym::random();
9688 let _rx = mgr.pre_populate_session_with_receiver(pseudonym, DestinationRouting::Return(pseudonym.into()));
9689
9690 // First packet fills the single slot and is accepted.
9691 let accepted = mgr.dispatch_message(pseudonym, session_data_packet(b"fills the slot")?);
9692 assert!(
9693 matches!(accepted, Ok(DispatchResult::Processed)),
9694 "first packet should be accepted, got {accepted:?}"
9695 );
9696
9697 // Second packet finds the inbox full → backpressure drop.
9698 let before = crate::counters::session_inbox_drop_count();
9699 let overflow = mgr.dispatch_message(pseudonym, session_data_packet(b"overflows")?);
9700 assert!(
9701 matches!(overflow, Ok(DispatchResult::Dropped(DropReason::SinkFull))),
9702 "overflow packet must be a Dropped(SinkFull) backpressure drop, got {overflow:?}"
9703 );
9704 assert!(crate::counters::session_inbox_drop_count() > before);
9705
9706 Ok(())
9707 }
9708
9709 /// A packet reporting that some of the SURBs it carried were dropped on arrival.
9710 /// An Entry-side slot whose organic SURB gate is closed: a target of 100 against a believed
9711 /// level of 200. Anything that reopens it in the tests below did so on the counterparty's word.
9712 fn entry_slot_with_closed_gate(
9713 mgr: &TestManager,
9714 pseudonym: HoprPseudonym,
9715 ) -> anyhow::Result<crossfire::AsyncRx<crossfire::mpsc::Array<ApplicationDataIn>>> {
9716 let rx = mgr.pre_populate_session_with_receiver(
9717 pseudonym,
9718 DestinationRouting::Forward {
9719 destination: Box::new(Address::from(&ChainKeypair::random()).into()),
9720 pseudonym: Some(pseudonym),
9721 forward_options: RoutingOptions::Hops(1.try_into()?),
9722 return_options: None,
9723 },
9724 );
9725
9726 let slot = mgr
9727 .sessions
9728 .get(&pseudonym)
9729 .ok_or_else(|| anyhow::anyhow!("slot must exist"))?;
9730 slot.surb_mgmt.update(&SurbBalancerConfig {
9731 target_surb_buffer_size: 100,
9732 ..Default::default()
9733 });
9734 slot.surb_mgmt
9735 .buffer_level
9736 .store(200, std::sync::atomic::Ordering::Relaxed);
9737 anyhow::ensure!(
9738 0 == slot.surb_mgmt.organic_surbs_per_packet(),
9739 "precondition: the gate must start closed"
9740 );
9741
9742 Ok(rx)
9743 }
9744
9745 /// The organic SURB cap the slot's gate currently answers.
9746 fn organic_surbs(mgr: &TestManager, pseudonym: HoprPseudonym) -> Option<usize> {
9747 mgr.sessions
9748 .get(&pseudonym)
9749 .map(|slot| slot.surb_mgmt.organic_surbs_per_packet())
9750 }
9751
9752 fn session_packet_signalling(signals: PacketSignals) -> anyhow::Result<ApplicationDataIn> {
9753 let mut data = session_data_packet(b"reply")?;
9754 data.packet_info.signals_from_sender = signals;
9755 Ok(data)
9756 }
9757
9758 fn keep_alive_packet_signalling(
9759 session_id: SessionId,
9760 signals: PacketSignals,
9761 ) -> anyhow::Result<ApplicationDataIn> {
9762 Ok(ApplicationDataIn {
9763 data: ApplicationData::try_from(HoprStartProtocol::KeepAlive(KeepAliveMessage {
9764 session_id,
9765 flags: KeepAliveFlag::BalancerState.into(),
9766 additional_data: 0,
9767 }))?,
9768 packet_info: IncomingPacketInfo {
9769 signals_from_sender: signals,
9770 ..Default::default()
9771 },
9772 })
9773 }
9774
9775 /// The regression: a packet dropped for local backpressure still carries what the counterparty
9776 /// said about its own SURB supply. Recorded only once the Session reads the packet, that word is
9777 /// lost exactly when the inbox is saturated — leaving organic production shut off on an estimate
9778 /// the counterparty has just contradicted, and with nothing else able to reopen it.
9779 #[test_log::test(tokio::test)]
9780 async fn distress_should_be_recorded_even_when_the_packet_is_dropped() -> anyhow::Result<()> {
9781 // Capacity 1, so the second packet deterministically finds the inbox full.
9782 let mgr: TestManager = SessionManager::new(SessionManagerConfig {
9783 session_forward_capacity: 1,
9784 ..Default::default()
9785 });
9786 let pseudonym = HoprPseudonym::random();
9787 let _rx = entry_slot_with_closed_gate(&mgr, pseudonym)?;
9788
9789 // Fill the single slot; nothing reads it.
9790 mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignals::default())?)?;
9791
9792 let dropped = mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignal::SurbDistress.into())?)?;
9793 assert!(
9794 matches!(dropped, DispatchResult::Dropped(DropReason::SinkFull)),
9795 "the fixture must actually drop this packet, got {dropped:?}"
9796 );
9797 assert_eq!(
9798 Some(1),
9799 organic_surbs(&mgr, pseudonym),
9800 "a dropped packet still spoke for the counterparty"
9801 );
9802
9803 Ok(())
9804 }
9805
9806 /// Keep-alives are all a Session that has stopped exchanging data still sends, and they reach
9807 /// `handle_keep_alive` stripped of their packet info — so before this was recorded at dispatch,
9808 /// a quiet counterparty could not report distress at all.
9809 #[test_log::test(tokio::test)]
9810 async fn distress_should_be_recorded_from_a_keep_alive() -> anyhow::Result<()> {
9811 let mgr: TestManager = SessionManager::new(Default::default());
9812 let pseudonym = HoprPseudonym::random();
9813 let _rx = entry_slot_with_closed_gate(&mgr, pseudonym)?;
9814
9815 // The manager is not started, so the Start-protocol worker rejects this after the signal has
9816 // been taken from it. That is the property under test: the record does not depend on
9817 // anything downstream succeeding.
9818 let _ = mgr.dispatch_message(
9819 pseudonym,
9820 keep_alive_packet_signalling(pseudonym, PacketSignal::OutOfSurbs.into())?,
9821 );
9822
9823 assert_eq!(Some(1), organic_surbs(&mgr, pseudonym));
9824
9825 Ok(())
9826 }
9827
9828 /// Distress is a running state, not a latch: the counterparty recomputes it per packet and stops
9829 /// setting it once its pool recovers, so a clean packet has to close the gate again.
9830 #[test_log::test(tokio::test)]
9831 async fn a_clean_packet_should_clear_distress() -> anyhow::Result<()> {
9832 let mgr: TestManager = SessionManager::new(Default::default());
9833 let pseudonym = HoprPseudonym::random();
9834 let _rx = entry_slot_with_closed_gate(&mgr, pseudonym)?;
9835
9836 mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignal::SurbDistress.into())?)?;
9837 assert_eq!(Some(1), organic_surbs(&mgr, pseudonym), "precondition: distress is set");
9838
9839 mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignals::default())?)?;
9840 assert_eq!(Some(0), organic_surbs(&mgr, pseudonym));
9841
9842 Ok(())
9843 }
9844
9845 /// The gate governs SURBs this side mints *for* its counterparty, which only the initiator does.
9846 /// On an incoming Session the same signal describes our own supply coming back to us, and acting
9847 /// on it would let a peer talk this side into minting for a buffer it does not keep.
9848 #[test_log::test(tokio::test)]
9849 async fn distress_should_be_ignored_on_an_incoming_session() -> anyhow::Result<()> {
9850 let mgr: TestManager = SessionManager::new(Default::default());
9851 let pseudonym = HoprPseudonym::random();
9852 let _rx = mgr.pre_populate_session_with_receiver(pseudonym, DestinationRouting::Return(pseudonym.into()));
9853
9854 let slot = mgr
9855 .sessions
9856 .get(&pseudonym)
9857 .ok_or_else(|| anyhow::anyhow!("slot must exist"))?;
9858 slot.surb_mgmt.update(&SurbBalancerConfig {
9859 target_surb_buffer_size: 100,
9860 ..Default::default()
9861 });
9862 slot.surb_mgmt
9863 .buffer_level
9864 .store(200, std::sync::atomic::Ordering::Relaxed);
9865
9866 mgr.dispatch_message(pseudonym, session_packet_signalling(PacketSignal::OutOfSurbs.into())?)?;
9867
9868 assert_eq!(
9869 Some(0),
9870 organic_surbs(&mgr, pseudonym),
9871 "an incoming Session must not take the signal as licence to mint"
9872 );
9873
9874 Ok(())
9875 }
9876
9877 /// Verifies that closing an existing session returns `true` and removes the session from the manager.
9878 ///
9879 /// ## Steps
9880 /// 1. A `SessionManager` is started with a mock transport that accepts one outbound message.
9881 /// 2. `handle_incoming_session_initiation` is called to create a session — one active session confirmed.
9882 /// 3. `close_session` is called with the session's pseudonym — returns `true`.
9883 /// 4. `num_active_sessions` is 0, confirming the session was fully removed.
9884 #[test_log::test(tokio::test)]
9885 async fn session_manager_should_return_true_when_closing_existing_session() -> anyhow::Result<()> {
9886 use hopr_utils::network_types::prelude::SealedHost;
9887
9888 let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
9889 SessionManager::new(Default::default());
9890
9891 let mut transport = MockMsgSender::new();
9892 transport
9893 .expect_send_message()
9894 .once()
9895 .returning(|_, _| futures::future::ok(()).boxed());
9896
9897 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
9898 let _notifications = tokio::spawn(async move {
9899 pin_mut!(new_session_rx);
9900 while let Some(_session) = new_session_rx.next().await {}
9901 });
9902 let (sender, _handle) = mock_packet_planning(transport);
9903 mgr.start(sender.clone(), new_session_tx, None, None)?;
9904 assert!(mgr.is_started());
9905
9906 // Create a session
9907 let pseudonym = HoprPseudonym::random();
9908 mgr.handle_incoming_session_initiation(
9909 pseudonym,
9910 StartInitiation {
9911 challenge: MIN_CHALLENGE,
9912 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
9913 capabilities: HoprSessionCapabilities::empty(),
9914 additional_data: 0,
9915 },
9916 )
9917 .await?;
9918
9919 // Verify session exists
9920 assert_eq!(mgr.active_sessions().len(), 1);
9921 assert_eq!(mgr.num_active_sessions(), 1);
9922
9923 // Close the session - should return true
9924 let result = mgr.close_session(&pseudonym);
9925 assert!(result, "closing existing session should return true");
9926
9927 // Verify session is closed
9928 assert_eq!(mgr.active_sessions().len(), 0);
9929 assert_eq!(mgr.num_active_sessions(), 0);
9930
9931 // Cleanup: close sender and await handle
9932 sender.close_channel();
9933 _handle.await??;
9934
9935 Ok(())
9936 }
9937
9938 /// `mark_return_path_degraded` must mark exactly the Sessions routed to the given destination
9939 /// **that run a return path**, and return their pseudonyms (the SURB-generation keys the caller
9940 /// bumps), leaving Sessions bound for other destinations — and one-way Sessions with no return
9941 /// path — untouched.
9942 #[test_log::test(tokio::test)]
9943 async fn session_manager_mark_return_path_degraded_should_return_the_marked_pseudonyms() -> anyhow::Result<()> {
9944 use std::collections::HashSet;
9945
9946 let mgr = SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
9947
9948 let dest_a: Address = (&ChainKeypair::random()).into();
9949 let dest_b: Address = (&ChainKeypair::random()).into();
9950
9951 let hops = || RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN);
9952 let forward_to =
9953 |addr: Address, ps: HoprPseudonym, return_options: Option<RoutingOptions>| DestinationRouting::Forward {
9954 destination: Box::new(addr.into()),
9955 pseudonym: Some(ps),
9956 forward_options: hops(),
9957 return_options,
9958 };
9959
9960 // Two Sessions route to `dest_a` with a return path, one to `dest_b`, and one one-way Session
9961 // (no return path) to `dest_a` that must be skipped.
9962 let (ps_a1, ps_a2, ps_b, ps_a_oneway) = (
9963 HoprPseudonym::random(),
9964 HoprPseudonym::random(),
9965 HoprPseudonym::random(),
9966 HoprPseudonym::random(),
9967 );
9968 mgr.pre_populate_session(ps_a1, forward_to(dest_a, ps_a1, Some(hops())));
9969 mgr.pre_populate_session(ps_a2, forward_to(dest_a, ps_a2, Some(hops())));
9970 mgr.pre_populate_session(ps_b, forward_to(dest_b, ps_b, Some(hops())));
9971 mgr.pre_populate_session(ps_a_oneway, forward_to(dest_a, ps_a_oneway, None));
9972 mgr.sessions.run_pending_tasks();
9973
9974 let node_a = hopr_api::types::internal::NodeId::Chain(dest_a);
9975 let marked: HashSet<_> = mgr
9976 .mark_return_path_degraded(&node_a, Duration::from_secs(30))
9977 .into_iter()
9978 .collect();
9979 assert_eq!(
9980 marked,
9981 HashSet::from([ps_a1, ps_a2]),
9982 "only return-path Sessions routed to dest_a must be marked; the one-way Session is skipped"
9983 );
9984
9985 // A destination nothing routes to marks nothing.
9986 let node_c = hopr_api::types::internal::NodeId::Chain((&ChainKeypair::random()).into());
9987 assert!(
9988 mgr.mark_return_path_degraded(&node_c, Duration::from_secs(30))
9989 .is_empty(),
9990 "a destination with no Sessions must return no pseudonyms"
9991 );
9992
9993 Ok(())
9994 }
9995
9996 /// Verifies that an Exit `KeepAlive` updates the SURB buffer level and counts as returned service.
9997 ///
9998 /// ## Steps
9999 /// 1. A session slot is manually inserted into Alice's manager with a known `SurbBalancerConfig` and an initial
10000 /// buffer level of 100.
10001 /// 2. A `KeepAlive` message with `KeepAliveFlag::BalancerState` and `additional_data: 200` is constructed and
10002 /// dispatched to Alice's manager via `dispatch_message`.
10003 /// 3. The manager processes the keep-alive asynchronously; the test polls until the slot's `buffer_level` reaches
10004 /// 200 (with a 1-second timeout).
10005 /// 4. The buffer level is confirmed to be 200 and the returned-packet counter advances once.
10006 #[test_log::test(tokio::test)]
10007 async fn session_manager_should_update_buffer_level_on_keep_alive_with_balancer_state_flag() -> anyhow::Result<()> {
10008 use std::sync::atomic::Ordering;
10009
10010 let alice_pseudonym = HoprPseudonym::random();
10011 let session_id = alice_pseudonym;
10012 let initial_buffer_level = 100u64;
10013 let new_buffer_level = 200u64;
10014
10015 let balancer_cfg = SurbBalancerConfig {
10016 target_surb_buffer_size: 1000,
10017 max_surbs_per_sec: 100,
10018 ..Default::default()
10019 };
10020
10021 let alice_mgr =
10022 SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
10023
10024 let (new_session_tx, _) = futures::channel::mpsc::channel(1024);
10025 let (mock_sender, _) = futures::channel::mpsc::unbounded();
10026 let _ahs = alice_mgr.start(mock_sender, new_session_tx, None, None)?;
10027 assert!(alice_mgr.is_started());
10028
10029 let (dummy_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
10030 let peer_address: Address = (&ChainKeypair::random()).into();
10031 alice_mgr.sessions.insert(
10032 session_id,
10033 SessionSlot {
10034 session_tx: dummy_tx,
10035 routing_opts: DestinationRouting::Forward {
10036 destination: Box::new(peer_address.into()),
10037 pseudonym: Some(alice_pseudonym),
10038 forward_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN),
10039 return_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN).into(),
10040 },
10041 abort_handles: Default::default(),
10042 surb_mgmt: Arc::new(BalancerStateValues::from(balancer_cfg)),
10043 surb_estimator: Default::default(),
10044 current_ssa_state: Default::default(),
10045 pix_supervisor: Default::default(),
10046 pix_egress_gate: Default::default(),
10047 pix_fill: Default::default(),
10048 returned_packets: Default::default(),
10049 cycle_budget: None,
10050 },
10051 );
10052
10053 // Set initial buffer level
10054 let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10055 session_slot
10056 .surb_mgmt
10057 .buffer_level
10058 .store(initial_buffer_level, Ordering::Relaxed);
10059 drop(session_slot);
10060
10061 // Verify initial buffer level
10062 let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10063 assert_eq!(session_slot.surb_mgmt.buffer_level(), initial_buffer_level);
10064 drop(session_slot);
10065
10066 // Create keep-alive message with BalancerState flag
10067 let ka = KeepAliveMessage::<SessionId> {
10068 session_id,
10069 flags: KeepAliveFlag::BalancerState.into(),
10070 additional_data: new_buffer_level,
10071 };
10072 let app_data: ApplicationData = HoprStartProtocol::KeepAlive(ka).try_into()?;
10073 let app_data_in = ApplicationDataIn {
10074 data: app_data,
10075 packet_info: Default::default(),
10076 };
10077
10078 // Dispatch the keep-alive message
10079 alice_mgr.dispatch_message(alice_pseudonym, app_data_in)?;
10080
10081 // Poll until the background task has processed the keep-alive
10082 tokio::time::timeout(Duration::from_secs(1), async {
10083 loop {
10084 if let Some(slot) = alice_mgr.sessions.get(&session_id)
10085 && slot.surb_mgmt.buffer_level() == new_buffer_level
10086 {
10087 break;
10088 }
10089 tokio::time::sleep(Duration::from_millis(10)).await;
10090 }
10091 })
10092 .await
10093 .context("keep-alive BalancerState update timed out")?;
10094
10095 // Verify buffer level was updated
10096 let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10097 assert_eq!(
10098 session_slot.surb_mgmt.buffer_level(),
10099 new_buffer_level,
10100 "buffer level should be updated via keep-alive with BalancerState flag"
10101 );
10102 assert_eq!(
10103 1,
10104 session_slot.returned_packets.load(Ordering::Relaxed),
10105 "an Exit keep-alive consumes a return SURB and must count toward the successor gate"
10106 );
10107
10108 Ok(())
10109 }
10110
10111 /// Verifies that a `KeepAlive` message with the `BalancerTarget` flag updates the session's
10112 /// target SURB buffer size in the manager.
10113 ///
10114 /// ## Steps
10115 /// 1. A session slot is manually inserted into Alice's manager with a known `SurbBalancerConfig` and
10116 /// `target_surb_buffer_size: 1000`.
10117 /// 2. A `KeepAlive` message with `KeepAliveFlag::BalancerTarget` and `additional_data: 2000` is constructed and
10118 /// dispatched via `dispatch_message`.
10119 /// 3. The manager processes the keep-alive asynchronously; the test polls until the slot's
10120 /// `target_surb_buffer_size` reaches 2000 (with a 1-second timeout).
10121 /// 4. The target is confirmed to be 2000, proving the `BalancerTarget` flag updated it.
10122 #[test_log::test(tokio::test)]
10123 async fn session_manager_should_update_target_on_keep_alive_with_balancer_target_flag() -> anyhow::Result<()> {
10124 use std::sync::atomic::Ordering;
10125
10126 let alice_pseudonym = HoprPseudonym::random();
10127 let session_id = alice_pseudonym;
10128 let initial_target = 1000u64;
10129 let new_target = 2000u64;
10130
10131 let balancer_cfg = SurbBalancerConfig {
10132 target_surb_buffer_size: initial_target,
10133 max_surbs_per_sec: 100,
10134 ..Default::default()
10135 };
10136
10137 let alice_mgr =
10138 SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
10139
10140 let (new_session_tx, _) = futures::channel::mpsc::channel(1024);
10141 let (mock_sender, _) = futures::channel::mpsc::unbounded();
10142 let _ahs = alice_mgr.start(mock_sender, new_session_tx, None, None)?;
10143 assert!(alice_mgr.is_started());
10144
10145 let (dummy_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
10146 alice_mgr.sessions.insert(
10147 session_id,
10148 SessionSlot {
10149 session_tx: dummy_tx,
10150 routing_opts: DestinationRouting::Return(SurbMatcher::Pseudonym(alice_pseudonym)),
10151 abort_handles: Default::default(),
10152 surb_mgmt: Arc::new(BalancerStateValues::from(balancer_cfg)),
10153 surb_estimator: Default::default(),
10154 current_ssa_state: Default::default(),
10155 pix_supervisor: Default::default(),
10156 pix_egress_gate: Default::default(),
10157 pix_fill: Default::default(),
10158 returned_packets: Default::default(),
10159 cycle_budget: None,
10160 },
10161 );
10162
10163 // Verify initial target
10164 let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10165 assert_eq!(
10166 session_slot.surb_mgmt.controller_bounds().target(),
10167 initial_target,
10168 "initial target should be set"
10169 );
10170 drop(session_slot);
10171
10172 // Create keep-alive message with BalancerTarget flag
10173 let ka = KeepAliveMessage::<SessionId> {
10174 session_id,
10175 flags: KeepAliveFlag::BalancerTarget.into(),
10176 additional_data: new_target,
10177 };
10178 let app_data: ApplicationData = HoprStartProtocol::KeepAlive(ka).try_into()?;
10179 let app_data_in = ApplicationDataIn {
10180 data: app_data,
10181 packet_info: Default::default(),
10182 };
10183
10184 // Dispatch the keep-alive message
10185 alice_mgr.dispatch_message(alice_pseudonym, app_data_in)?;
10186
10187 // Poll until the background task has processed the keep-alive
10188 tokio::time::timeout(Duration::from_secs(1), async {
10189 loop {
10190 if let Some(slot) = alice_mgr.sessions.get(&session_id)
10191 && slot.surb_mgmt.target_surb_buffer_size.load(Ordering::Relaxed) == new_target
10192 {
10193 break;
10194 }
10195 tokio::time::sleep(Duration::from_millis(10)).await;
10196 }
10197 })
10198 .await
10199 .context("keep-alive BalancerTarget update timed out")?;
10200
10201 // Verify target was updated
10202 let session_slot = alice_mgr.sessions.get(&session_id).unwrap();
10203 assert_eq!(
10204 session_slot.surb_mgmt.target_surb_buffer_size.load(Ordering::Relaxed),
10205 new_target,
10206 "target buffer size should be updated via keep-alive with BalancerTarget flag"
10207 );
10208 assert_eq!(
10209 0,
10210 session_slot.returned_packets.load(Ordering::Relaxed),
10211 "an Entry keep-alive received on the Exit must not count as returned Exit service"
10212 );
10213
10214 Ok(())
10215 }
10216
10217 /// Verifies that a session is evicted after the `idle_timeout` fires, without needing an explicit close.
10218 ///
10219 /// ## Steps
10220 /// 1. A `SessionManager` is configured with `maximum_sessions: 1` and `idle_timeout: 100ms`.
10221 /// 2. `handle_incoming_session_initiation` creates one session — confirmed active.
10222 /// 3. The test sleeps 200ms (well past the 100ms timeout), then calls `sessions.run_pending_tasks()` to drive the
10223 /// eviction timer.
10224 /// 4. `active_sessions` is empty, confirming the idle session was cleaned up without an explicit close call.
10225 #[test_log::test(tokio::test)]
10226 async fn session_manager_should_evict_idle_session_and_call_close_callback() -> anyhow::Result<()> {
10227 use hopr_utils::network_types::prelude::SealedHost;
10228
10229 let cfg = SessionManagerConfig {
10230 maximum_sessions: 1,
10231 idle_timeout: Duration::from_millis(100),
10232 ..Default::default()
10233 };
10234 let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10235 SessionManager::new(cfg);
10236
10237 let mut transport = MockMsgSender::new();
10238 transport
10239 .expect_send_message()
10240 .times(1)
10241 .returning(|_, _| futures::future::ok(()).boxed());
10242
10243 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
10244 let _notifications = tokio::spawn(async move {
10245 pin_mut!(new_session_rx);
10246 while let Some(_session) = new_session_rx.next().await {}
10247 });
10248 let (sender, _handle) = mock_packet_planning(transport);
10249 mgr.start(sender.clone(), new_session_tx, None, None)?;
10250 assert!(mgr.is_started());
10251
10252 // Create first session
10253 let pseudonym1 = HoprPseudonym::random();
10254 mgr.handle_incoming_session_initiation(
10255 pseudonym1,
10256 StartInitiation {
10257 challenge: MIN_CHALLENGE,
10258 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10259 capabilities: HoprSessionCapabilities::empty(),
10260 additional_data: 0,
10261 },
10262 )
10263 .await?;
10264
10265 // Verify first session exists
10266 assert_eq!(mgr.active_sessions().len(), 1);
10267
10268 // Wait for the session to expire (idle_timeout = 100ms)
10269 tokio::time::sleep(Duration::from_millis(200)).await;
10270 mgr.sessions.run_pending_tasks();
10271
10272 // Verify session was evicted (cache should be empty now)
10273 assert_eq!(
10274 mgr.active_sessions().len(),
10275 0,
10276 "idle session should be evicted after timeout"
10277 );
10278
10279 Ok(())
10280 }
10281
10282 /// Verifies that a second incoming session initiation is rejected (not evicted) when the manager
10283 /// is at `maximum_sessions` capacity with a long `idle_timeout`.
10284 ///
10285 /// ## Steps
10286 /// 1. A `SessionManager` is configured with `maximum_sessions: 1` and `idle_timeout: 3600s` (long enough that
10287 /// eviction will not fire during the test).
10288 /// 2. `handle_incoming_session_initiation` creates session `X1` — confirmed active.
10289 /// 3. `handle_incoming_session_initiation` is called for session `X2` — the manager detects capacity is reached and
10290 /// rejects the initiation internally (sends `SessionError`).
10291 /// 4. `active_sessions` still contains only `X1`; the first session was not evicted to make room.
10292 #[test_log::test(tokio::test)]
10293 async fn session_manager_should_reject_new_session_when_max_sessions_reached_no_eviction() -> anyhow::Result<()> {
10294 use hopr_utils::network_types::prelude::SealedHost;
10295
10296 // Create manager with max 1 session
10297 let cfg = SessionManagerConfig {
10298 maximum_sessions: 1,
10299 idle_timeout: Duration::from_secs(3600), // Long timeout so eviction doesn't happen
10300 ..Default::default()
10301 };
10302 let mgr: SessionManager<futures::channel::mpsc::UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10303 SessionManager::new(cfg);
10304
10305 let mut transport = MockMsgSender::new();
10306 transport
10307 .expect_send_message()
10308 .times(2)
10309 .returning(|_, _| futures::future::ok(()).boxed());
10310
10311 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
10312 let _notifications = tokio::spawn(async move {
10313 pin_mut!(new_session_rx);
10314 while let Some(_session) = new_session_rx.next().await {}
10315 });
10316 let (sender, _handle) = mock_packet_planning(transport);
10317 mgr.start(sender.clone(), new_session_tx, None, None)?;
10318 assert!(mgr.is_started());
10319
10320 // Create first session
10321 let pseudonym1 = HoprPseudonym::random();
10322 mgr.handle_incoming_session_initiation(
10323 pseudonym1,
10324 StartInitiation {
10325 challenge: MIN_CHALLENGE,
10326 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10327 capabilities: HoprSessionCapabilities::empty(),
10328 additional_data: 0,
10329 },
10330 )
10331 .await?;
10332
10333 // Verify first session exists
10334 assert_eq!(mgr.active_sessions().len(), 1);
10335
10336 // Try to create second session - should be rejected (not evicted)
10337 let pseudonym2 = HoprPseudonym::random();
10338 let _result = mgr
10339 .handle_incoming_session_initiation(
10340 pseudonym2,
10341 StartInitiation {
10342 challenge: MIN_CHALLENGE,
10343 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10344 capabilities: HoprSessionCapabilities::empty(),
10345 additional_data: 0,
10346 },
10347 )
10348 .await;
10349
10350 // Should still have exactly 1 session (the first one)
10351 assert_eq!(
10352 mgr.active_sessions().len(),
10353 1,
10354 "should still have exactly one session - second session should be rejected"
10355 );
10356
10357 // The active session should be the first one (second was rejected)
10358 assert!(
10359 mgr.active_sessions().contains(&pseudonym1),
10360 "the first session should still be active"
10361 );
10362
10363 // Cleanup: close sender and await handle
10364 sender.close_channel();
10365 _handle.await??;
10366
10367 Ok(())
10368 }
10369
10370 /// Verifies that `new_session` rejects `UsePIX` when the return path has 0 intermediate hops.
10371 ///
10372 /// PIX shares are encrypted with the first relayer's ticket-challenge solution and carried
10373 /// in return-path SURBs. With 0 intermediate hops (a direct Exit→Entry SURB), there is no
10374 /// relayer to provide the challenge solution, so shares are never embedded — the ongoing
10375 /// PIX share delivery mechanism is dead and the Exit's quota is never replenished.
10376 #[test_log::test(tokio::test)]
10377 async fn new_session_rejects_usepix_with_zero_return_hops() -> anyhow::Result<()> {
10378 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10379 SessionManager::new(Default::default());
10380
10381 let mut transport = MockMsgSender::new();
10382 // The error happens before any message is sent, so expect_send_message should NOT fire.
10383 transport.expect_send_message().times(0);
10384
10385 let (sender, _handle) = mock_packet_planning(transport);
10386 let (new_session_tx, _) = futures::channel::mpsc::channel(1);
10387 mgr.start(sender.clone(), new_session_tx, None, None)?;
10388 assert!(mgr.is_started());
10389
10390 let result = mgr
10391 .new_session(
10392 Address::from(&ChainKeypair::random()),
10393 SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10394 SessionClientConfig {
10395 capabilities: Capability::UsePIX.into(),
10396 surb_management: None,
10397 forward_path_options: RoutingOptions::Hops(1.try_into()?),
10398 return_path_options: RoutingOptions::Hops(0.try_into()?),
10399 ..Default::default()
10400 },
10401 )
10402 .await;
10403
10404 let err = result.unwrap_err();
10405 let msg = format!("{err:?}");
10406 assert!(
10407 msg.contains("UsePIX requires at least 1 intermediate hop on the return path"),
10408 "expected return-path guard error, got: {msg}"
10409 );
10410
10411 assert_eq!(mgr.num_active_sessions(), 0);
10412 // No challenge slot was consumed because the validations run before insert_into_next_slot.
10413 assert_eq!(
10414 mgr.session_initiations.entry_count(),
10415 0,
10416 "session_initiations must remain empty when UsePIX is rejected"
10417 );
10418
10419 sender.close_channel();
10420 _handle.await??;
10421 Ok(())
10422 }
10423
10424 /// Verifies that `new_session` rejects `UsePIX` on a node with no `PixToolbox` installed.
10425 ///
10426 /// Since the announced dimensions are read off the installed generator rather than named by the
10427 /// caller, the presence of that generator is the *only* precondition left for an outgoing PIX
10428 /// Session — there is no longer a caller-supplied value whose absence would fail first. A node
10429 /// that advertised `UsePIX` without one would establish a Session it cannot produce a single
10430 /// share for.
10431 ///
10432 /// The return path carries 2 intermediate hops so the zero-hop guard above cannot be what
10433 /// rejects this, and the assertion names the toolbox error specifically for the same reason.
10434 #[test_log::test(tokio::test)]
10435 async fn new_session_rejects_usepix_when_no_pix_toolbox_is_installed() -> anyhow::Result<()> {
10436 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10437 SessionManager::new(Default::default());
10438
10439 let mut transport = MockMsgSender::new();
10440 // The error happens before any message is sent, so expect_send_message should NOT fire.
10441 transport.expect_send_message().times(0);
10442
10443 let (sender, _handle) = mock_packet_planning(transport);
10444 let (new_session_tx, _) = futures::channel::mpsc::channel(1);
10445 // `None` for the toolbox: this is a node that cannot serve PIX at all.
10446 mgr.start(sender.clone(), new_session_tx, None, None)?;
10447 assert!(mgr.is_started());
10448
10449 let result = mgr
10450 .new_session(
10451 Address::from(&ChainKeypair::random()),
10452 SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10453 SessionClientConfig {
10454 capabilities: Capability::UsePIX.into(),
10455 surb_management: None,
10456 forward_path_options: RoutingOptions::Hops(1.try_into()?),
10457 return_path_options: RoutingOptions::Hops(2.try_into()?),
10458 ..Default::default()
10459 },
10460 )
10461 .await;
10462
10463 let msg = format!("{:?}", result.unwrap_err());
10464 assert!(
10465 msg.contains("UsePIX requested but no PIX toolbox installed"),
10466 "expected missing-toolbox error, got: {msg}"
10467 );
10468
10469 assert_eq!(mgr.num_active_sessions(), 0);
10470 // No challenge slot was consumed because the validations run before insert_into_next_slot.
10471 assert_eq!(
10472 mgr.session_initiations.entry_count(),
10473 0,
10474 "session_initiations must remain empty when UsePIX is rejected for a missing toolbox"
10475 );
10476
10477 sender.close_channel();
10478 _handle.await??;
10479 Ok(())
10480 }
10481
10482 // ---------------------------------------------------------------------------
10483 // PIX protocol tests
10484 // ---------------------------------------------------------------------------
10485
10486 /// The fill fractions must be range-checked before anything computes with them.
10487 ///
10488 /// This validator runs *before* `validate_pix_supervision` on both paths that reach it:
10489 /// `SessionManager::start` calls it first, and in `HoprProtocolConfig` it is a field validator
10490 /// while the other is a schema-level one. So it cannot assume the ranges have already been
10491 /// checked — and it feeds `finish_fraction` straight into `Duration::mul_f64`, which panics on a
10492 /// `NaN`, on a negative, and on a product the monotonic clock cannot hold. A single
10493 /// `finish_fraction: .nan` in a YAML file would therefore abort the node at load instead of being
10494 /// reported as the configuration error it is.
10495 ///
10496 /// The finite out-of-range values do not panic, which is why they are here too: they compute a
10497 /// floor that is wrong in the permissive direction, and nothing downstream ever says so.
10498 #[test]
10499 fn fill_fractions_are_rejected_before_the_ceiling_arithmetic_runs() {
10500 let with_fill = |fill: crate::supervision::PixFillConfig| IncomingSessionPixConfig {
10501 supervision: SupervisorConfig {
10502 fill,
10503 ..Default::default()
10504 },
10505 ..Default::default()
10506 };
10507
10508 for finish_fraction in [f64::NAN, -1.0, f64::INFINITY, 2.0] {
10509 let cfg = with_fill(crate::supervision::PixFillConfig {
10510 finish_fraction,
10511 ..Default::default()
10512 });
10513 let outcome = validate_incoming_session_pix_config(&cfg, MAX_ASSUMED_SESSION_PACKET_RATE);
10514 assert!(
10515 matches!(&outcome, Err(TransportSessionError::InvalidConfig(msg)) if msg.contains("fill.finish_fraction")),
10516 "a finish_fraction of {finish_fraction} must be refused rather than multiplied, got {outcome:?}"
10517 );
10518 }
10519
10520 for loss_margin in [f64::NAN, -0.5, 1.0] {
10521 let cfg = with_fill(crate::supervision::PixFillConfig {
10522 loss_margin,
10523 ..Default::default()
10524 });
10525 let outcome = validate_incoming_session_pix_config(&cfg, MAX_ASSUMED_SESSION_PACKET_RATE);
10526 assert!(
10527 matches!(&outcome, Err(TransportSessionError::InvalidConfig(msg)) if msg.contains("fill.loss_margin")),
10528 "a loss_margin of {loss_margin} must be refused, got {outcome:?}"
10529 );
10530 }
10531
10532 validate_incoming_session_pix_config(&IncomingSessionPixConfig::default(), MAX_ASSUMED_SESSION_PACKET_RATE)
10533 .expect("the shipped defaults must still validate");
10534 }
10535
10536 /// A zero recovery deadline must be named as such on both fill paths, rather than reaching the
10537 /// fill arithmetic and being reported as an infinite rate requirement.
10538 #[test]
10539 fn a_zero_recovery_deadline_is_refused_as_zero_with_and_without_fill() {
10540 for enabled in [true, false] {
10541 let cfg = IncomingSessionPixConfig {
10542 supervision: SupervisorConfig {
10543 max_recovery_time: Duration::ZERO,
10544 fill: crate::supervision::PixFillConfig {
10545 enabled,
10546 ..Default::default()
10547 },
10548 ..Default::default()
10549 },
10550 ..Default::default()
10551 };
10552 let outcome = validate_incoming_session_pix_config(&cfg, MAX_ASSUMED_SESSION_PACKET_RATE);
10553 assert!(
10554 matches!(&outcome, Err(TransportSessionError::InvalidConfig(msg)) if msg.contains("max_recovery_time must be non-zero")),
10555 "with fill enabled = {enabled}, a zero deadline must be refused as zero, got {outcome:?}"
10556 );
10557 }
10558 }
10559
10560 /// The fill heartbeat is clamped at *both* ends for a programmatically assembled config.
10561 ///
10562 /// The upper clamp is the one every other supervisor duration takes. The lower one is what stops
10563 /// a zero period, which `validate_pix_supervision` rejects and nothing in this crate calls: with
10564 /// it, `FillRate::as_packets_per_sec` reports zero for the heartbeat, so the planner's floor is
10565 /// zero, both heartbeat branches of `FillPlanner::bound` return a zero-period rate, and
10566 /// `PixFillControl::apply` hands `set_rate_per_unit(1, 1µs)` to the controller — which
10567 /// `RateController::MIN_DELAY` turns into 10 000 packets/s. The `max_rate` ceiling never sees it,
10568 /// so the one bound on this node's self-generated egress would be bypassed by the one field left
10569 /// unbounded below.
10570 #[test]
10571 fn programmatic_fill_heartbeat_is_clamped_to_a_positive_period() {
10572 let normalized = |heartbeat: Duration| {
10573 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10574 SessionManager::new(SessionManagerConfig {
10575 pix_config: IncomingSessionPixConfig {
10576 supervision: SupervisorConfig {
10577 fill: crate::supervision::PixFillConfig {
10578 heartbeat,
10579 ..Default::default()
10580 },
10581 ..Default::default()
10582 },
10583 ..Default::default()
10584 },
10585 ..Default::default()
10586 });
10587 mgr.cfg.pix_config.supervision.fill.heartbeat
10588 };
10589
10590 // The exact dual of the `max_rate` ceiling, so the two clamps cannot drift apart.
10591 let floor = Duration::from_secs(1) / crate::supervision::MAX_FILL_RATE;
10592 assert_eq!(
10593 floor,
10594 normalized(Duration::ZERO),
10595 "a zero heartbeat must be raised to the period of MAX_FILL_RATE"
10596 );
10597 assert_eq!(
10598 crate::supervision::MAX_SUPERVISOR_DURATION,
10599 normalized(Duration::MAX),
10600 "an unrepresentable heartbeat must be lowered to the same cap every other deadline takes"
10601 );
10602
10603 // Which is the property the clamp exists for: a rate the planner can divide by.
10604 assert!(
10605 FillRate::once_per(normalized(Duration::ZERO)).as_packets_per_sec() > 0.0,
10606 "the clamped heartbeat must be a rate the planner can compare against its ceiling"
10607 );
10608 }
10609
10610 /// Supervisor durations a programmatic caller supplies must be clamped, not trusted.
10611 ///
10612 /// `validate_pix_supervision` rejects these, but nothing in this crate calls `validate` — a
10613 /// `SessionManagerConfig` assembled in code reaches the supervisor exactly as written. And the
10614 /// failure is silent in the worst direction: `Instant::checked_add` returns `None` for a duration
10615 /// the monotonic clock cannot represent, and every phase reads an absent deadline as *no
10616 /// deadline*, so the over-large value disables the rule rather than relaxing it.
10617 ///
10618 /// The two batch-scaled deadlines are clamped by what is actually armed, so a batch of `n` leaves
10619 /// each of them at a cap `n` times smaller.
10620 #[test]
10621 fn programmatic_supervisor_durations_are_clamped_to_a_representable_deadline() {
10622 const BATCH: usize = 4;
10623 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10624 SessionManager::new(SessionManagerConfig {
10625 pix_config: IncomingSessionPixConfig {
10626 supervision: SupervisorConfig {
10627 ssas_per_request: BATCH,
10628 max_ssa_delivery_time: Duration::MAX,
10629 max_deposit_wait: Duration::MAX,
10630 max_recovery_idle: Duration::MAX,
10631 max_recovery_time: Duration::MAX,
10632 tombstone_retention_window: Duration::MAX,
10633 ..Default::default()
10634 },
10635 ..Default::default()
10636 },
10637 ..Default::default()
10638 });
10639
10640 let sup = &mgr.cfg.pix_config.supervision;
10641 let cap = crate::supervision::MAX_SUPERVISOR_DURATION;
10642 let per_cycle_cap = cap / BATCH as u32;
10643
10644 assert_eq!(per_cycle_cap, sup.max_ssa_delivery_time);
10645 assert_eq!(per_cycle_cap, sup.max_deposit_wait);
10646 assert_eq!(cap, sup.max_recovery_time);
10647 assert_eq!(cap, sup.tombstone_retention_window);
10648
10649 // `max_recovery_idle` is bounded by something tighter than the clock: it must stay strictly
10650 // under the reconstructor's `unused_verifier_lifetime`, or the supervisor waits on a cycle
10651 // whose state was reclaimed long before. Normalized against the default reconstructor, which
10652 // is the one `SessionManager::new` can see; `start` re-checks against the installed one.
10653 let lifetime = hopr_protocol_pix::SsaReconstructorConfig::default().unused_verifier_lifetime;
10654 assert!(
10655 sup.max_recovery_idle < lifetime,
10656 "clamped max_recovery_idle ({:?}) must stay under the reconstructor lifetime ({lifetime:?})",
10657 sup.max_recovery_idle
10658 );
10659 crate::supervision::validate_pix_supervision(sup, &hopr_protocol_pix::SsaReconstructorConfig::default())
10660 .expect("a normalized config must satisfy every cross-component invariant, not only representability");
10661
10662 // Which is exactly the condition that makes every deadline representable.
10663 let now = std::time::Instant::now();
10664 for dur in [
10665 crate::supervision::scaled_deadline(sup.max_ssa_delivery_time, sup.ssas_per_request),
10666 crate::supervision::scaled_deadline(sup.max_deposit_wait, sup.ssas_per_request),
10667 sup.max_recovery_idle,
10668 sup.max_recovery_time,
10669 sup.tombstone_retention_window,
10670 ] {
10671 assert!(
10672 now.checked_add(dur).is_some(),
10673 "a clamped duration ({dur:?}) must still produce a deadline"
10674 );
10675 }
10676 }
10677
10678 /// Normalizing against the default reconstructor is not enough when a caller installs a
10679 /// different one.
10680 ///
10681 /// `SessionManager::new` cannot see the reconstructor — it arrives with the toolbox at `start` —
10682 /// so its clamp can only assume the defaults. A caller pairing a shorter
10683 /// `unused_verifier_lifetime` with a supervisor config that was valid against the defaults gets
10684 /// a supervisor that outlives the state it is waiting on. That must be an error rather than a
10685 /// silent clamp: at `start` both halves were chosen deliberately, and overriding one of them
10686 /// would be answering a question the caller did not ask.
10687 #[test]
10688 fn start_rejects_a_supervisor_config_the_installed_reconstructor_cannot_support() -> anyhow::Result<()> {
10689 use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
10690
10691 // Valid against the defaults — 10 min sits well under the default 30 min lifetime — and
10692 // therefore untouched by the constructor's clamp.
10693 let cfg = SessionManagerConfig {
10694 pix_config: IncomingSessionPixConfig {
10695 supervision: SupervisorConfig {
10696 max_recovery_idle: Duration::from_secs(600),
10697 ..Default::default()
10698 },
10699 ..Default::default()
10700 },
10701 ..Default::default()
10702 };
10703 crate::supervision::validate_pix_supervision(&cfg.pix_config.supervision, &SsaReconstructorConfig::default())
10704 .expect("the fixture must be valid against the default reconstructor");
10705
10706 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> = SessionManager::new(cfg);
10707
10708 // But the reconstructor actually installed reclaims a cycle after 60 s.
10709 let (pix_toolbox, _pix_events) = PixToolbox::new(
10710 SsaShareGenerator::new(SsaGeneratorConfig::default()).into(),
10711 SsaReconstructor::new(SsaReconstructorConfig {
10712 unused_verifier_lifetime: Duration::from_secs(60),
10713 ..Default::default()
10714 })
10715 .into(),
10716 );
10717
10718 let (tx, _rx) = futures::channel::mpsc::unbounded();
10719 let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(1);
10720 let started = mgr.start(tx, new_session_tx, Some(pix_toolbox), None);
10721
10722 assert!(
10723 matches!(started, Err(TransportSessionError::InvalidConfig(_))),
10724 "starting with a reconstructor that cannot support the supervisor config must fail, got {started:?}"
10725 );
10726
10727 Ok(())
10728 }
10729
10730 /// A programmatically assembled manager must enforce the same incoming-PIX cross-field rules
10731 /// as the serialized transport configuration before it starts accepting Sessions.
10732 #[allow(clippy::reversed_empty_ranges)]
10733 #[test_log::test(tokio::test)]
10734 async fn start_rejects_each_invalid_incoming_pix_cross_field_combination() -> anyhow::Result<()> {
10735 let valid = IncomingSessionPixConfig::default();
10736 let widest = max_cycle_budget_for_quota(*valid.quota_range.end(), valid.supervision.ssas_per_request);
10737 let invalid_configs = [
10738 (
10739 "empty quota range",
10740 IncomingSessionPixConfig {
10741 quota_range: 100..=10,
10742 ..valid.clone()
10743 },
10744 "quota_range",
10745 ),
10746 (
10747 "recovery deadline below one accepted cycle",
10748 IncomingSessionPixConfig {
10749 supervision: SupervisorConfig {
10750 max_recovery_time: Duration::from_secs(1),
10751 ..valid.supervision.clone()
10752 },
10753 ..valid.clone()
10754 },
10755 "max_recovery_time",
10756 ),
10757 (
10758 "live-cycle budget below one accepted cycle",
10759 IncomingSessionPixConfig {
10760 max_live_cycle_bytes: widest - 1,
10761 ..valid
10762 },
10763 "max_live_cycle_bytes",
10764 ),
10765 ];
10766
10767 for (case, pix_config, expected_field) in invalid_configs {
10768 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10769 SessionManager::new(SessionManagerConfig {
10770 pix_config,
10771 ..Default::default()
10772 });
10773 let (pix_toolbox, _pix_events) = PixToolbox::new(
10774 SsaShareGenerator::new(SsaGeneratorConfig::default()).into(),
10775 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
10776 );
10777 let (tx, _rx) = futures::channel::mpsc::unbounded();
10778 let (new_session_tx, _new_session_rx) = futures::channel::mpsc::channel(1);
10779
10780 match mgr.start(tx, new_session_tx, Some(pix_toolbox), None) {
10781 Err(TransportSessionError::InvalidConfig(message)) => assert!(
10782 message.contains(expected_field),
10783 "{case} should identify {expected_field}, got: {message}"
10784 ),
10785 Err(error) => anyhow::bail!("{case} returned the wrong error: {error}"),
10786 Ok(_) => anyhow::bail!("{case} was accepted"),
10787 }
10788 }
10789
10790 Ok(())
10791 }
10792
10793 /// A rejected PIX configuration must not consume the manager's one-shot start state.
10794 ///
10795 /// Validation is fallible and happens before any worker is spawned, so callers must be able to
10796 /// correct the toolbox (or start without PIX) and retry the same manager. Otherwise `start`
10797 /// reports an ordinary configuration error while leaving `is_started() == false`, but the
10798 /// already-filled message-sender lock makes every retry fail with `AlreadyStarted`.
10799 ///
10800 /// On a runtime because the retry now gets far enough to spawn the manager's workers, which is
10801 /// the whole point — before the fix it returned `AlreadyStarted` before reaching them.
10802 #[test_log::test(tokio::test)]
10803 async fn rejected_pix_start_does_not_poison_a_retry() -> anyhow::Result<()> {
10804 use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
10805
10806 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10807 SessionManager::new(SessionManagerConfig {
10808 pix_config: IncomingSessionPixConfig {
10809 supervision: SupervisorConfig {
10810 max_recovery_idle: Duration::from_secs(600),
10811 ..Default::default()
10812 },
10813 ..Default::default()
10814 },
10815 ..Default::default()
10816 });
10817
10818 let (invalid_pix, _pix_events) = PixToolbox::new(
10819 SsaShareGenerator::new(SsaGeneratorConfig::default()).into(),
10820 SsaReconstructor::new(SsaReconstructorConfig {
10821 unused_verifier_lifetime: Duration::from_secs(60),
10822 ..Default::default()
10823 })
10824 .into(),
10825 );
10826 let (first_tx, _first_rx) = futures::channel::mpsc::unbounded();
10827 let (first_notifier, _first_notifications) = futures::channel::mpsc::channel(1);
10828 assert!(matches!(
10829 mgr.start(first_tx, first_notifier, Some(invalid_pix), None),
10830 Err(TransportSessionError::InvalidConfig(_))
10831 ));
10832 assert!(
10833 !mgr.is_started(),
10834 "a rejected start must not report the manager as running"
10835 );
10836
10837 let (retry_tx, _retry_rx) = futures::channel::mpsc::unbounded();
10838 let (retry_notifier, _retry_notifications) = futures::channel::mpsc::channel(1);
10839 let retry = mgr.start(retry_tx, retry_notifier, None, None);
10840 assert!(
10841 retry.is_ok(),
10842 "a configuration error must leave the manager retryable, got {retry:?}"
10843 );
10844
10845 Ok(())
10846 }
10847
10848 /// Making every duration representable is not sufficient if normalization leaves a supervisor
10849 /// waiting after its paired reconstructor has already discarded the cycle. A programmatic
10850 /// caller using the default reconstructor must receive a configuration that still satisfies the
10851 /// same cross-component lifetime invariants as a file-loaded configuration.
10852 #[test]
10853 fn programmatic_clamping_preserves_reconstructor_lifetime_invariants() {
10854 let mgr: SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>> =
10855 SessionManager::new(SessionManagerConfig {
10856 pix_config: IncomingSessionPixConfig {
10857 supervision: SupervisorConfig {
10858 max_recovery_idle: Duration::MAX,
10859 ..Default::default()
10860 },
10861 ..Default::default()
10862 },
10863 ..Default::default()
10864 });
10865
10866 crate::supervision::validate_pix_supervision(
10867 &mgr.cfg.pix_config.supervision,
10868 &SsaReconstructorConfig::default(),
10869 )
10870 .expect(
10871 "normalizing a programmatic config must not leave recovery-idle beyond the reconstructor's state lifetime",
10872 );
10873 }
10874
10875 /// Verifies that an incoming Session whose PIX quota cannot reach the acceptable range within
10876 /// the configured dynamic batch ceiling is rejected with
10877 /// `StartErrorReason::UnacceptablePixParams`.
10878 ///
10879 /// ## Steps
10880 /// 1. Bob's manager accepts exactly two times the Entry's per-SSA quota, but leaves the dynamic batch ceiling at
10881 /// its default of one.
10882 /// 2. The test offers valid PIX dimensions whose quota would therefore need a batch of two.
10883 /// 3. `handle_incoming_session_initiation` is called with `Capability::UsePIX` and the out-of-range quota.
10884 /// 4. Bob's manager sends a `SessionError` back to the peer with reason `UnacceptablePixParams`.
10885 /// 5. The test receives the error on a one-shot channel and asserts `err.reason == UnacceptablePixParams` and
10886 /// `err.challenge == MIN_CHALLENGE`.
10887 /// 6. `num_active_sessions` is 0, confirming no session slot was created.
10888 #[test_log::test(tokio::test)]
10889 async fn incoming_session_with_unacceptable_pix_quota_is_rejected() -> anyhow::Result<()> {
10890 use std::sync::Arc;
10891
10892 use hopr_protocol_pix::SsaReconstructorConfig;
10893 use hopr_protocol_start::{StartErrorReason, StartInitiation};
10894 use tokio::sync::oneshot;
10895
10896 let params = small_pix_params();
10897 let quota_per_ssa = pix_params_to_quota(¶ms);
10898 let pix_toolbox = pix_toolbox_with_pool(
10899 SsaShareGenerator::new(SsaGeneratorConfig {
10900 polynomials_per_ssa: 2,
10901 threshold: 2,
10902 surplus_shares: TEST_SURPLUS_SHARES,
10903 })
10904 .into(),
10905 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
10906 );
10907
10908 let mgr = SessionManager::new(SessionManagerConfig {
10909 pix_config: IncomingSessionPixConfig {
10910 quota_range: 2 * quota_per_ssa..=2 * quota_per_ssa,
10911 ..Default::default()
10912 },
10913 ..Default::default()
10914 });
10915
10916 let mut bob_transport = MockMsgSender::new();
10917 let (tx, rx) = oneshot::channel();
10918 let tx = Arc::new(std::sync::Mutex::new(Some(tx)));
10919
10920 bob_transport.expect_send_message().returning(move |_, data| {
10921 let tx = tx.clone();
10922 Box::pin(async move {
10923 if let Ok(HoprStartProtocol::SessionError(err)) =
10924 HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
10925 && let Some(tx) = tx.lock().unwrap().take()
10926 {
10927 let _ = tx.send(err);
10928 }
10929 Ok(())
10930 })
10931 });
10932
10933 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
10934 let (new_session_tx, _) = futures::channel::mpsc::channel(1);
10935 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
10936
10937 let alice_pseudonym = HoprPseudonym::random();
10938
10939 mgr.handle_incoming_session_initiation(
10940 alice_pseudonym,
10941 StartInitiation {
10942 challenge: MIN_CHALLENGE,
10943 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
10944 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
10945 additional_data: into_initiation_additional_data(params.into_additional_data(0)),
10946 },
10947 )
10948 .await?;
10949
10950 let err = rx.await.context("send_message was never called")?;
10951 assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
10952 assert_eq!(err.identifier, ErrorIdentifier::Challenge(MIN_CHALLENGE));
10953 assert_eq!(mgr.num_active_sessions(), 0);
10954
10955 bob_sender.close_channel();
10956 bob_handle.await??;
10957 Ok(())
10958 }
10959
10960 /// Verifies that an incoming session initiation that does not declare `UsePIX` capability is
10961 /// rejected when PIX is enforced on the responder.
10962 ///
10963 /// ## Steps
10964 /// 1. Bob's manager is configured with `pix_config.enforce_pix: true`, requiring all incoming sessions to opt into
10965 /// PIX.
10966 /// 2. The incoming initiation carries `Capability::Segmentation` only (no `UsePIX`).
10967 /// 3. `handle_incoming_session_initiation` is called; Bob's manager detects the missing `UsePIX` capability and
10968 /// sends a `SessionError` with `UnacceptablePixParams`.
10969 /// 4. The test receives the error and asserts `err.reason == UnacceptablePixParams`.
10970 /// 5. `num_active_sessions` is 0, confirming no session slot was created.
10971 #[test_log::test(tokio::test)]
10972 async fn incoming_session_without_usepix_is_rejected_when_pix_enforced() -> anyhow::Result<()> {
10973 use std::sync::Arc;
10974
10975 use hopr_protocol_start::{StartErrorReason, StartInitiation};
10976 use tokio::sync::oneshot;
10977
10978 let mgr = SessionManager::new(SessionManagerConfig {
10979 pix_config: IncomingSessionPixConfig {
10980 enforce_pix: true,
10981 ..Default::default()
10982 },
10983 ..Default::default()
10984 });
10985
10986 let mut bob_transport = MockMsgSender::new();
10987 let (tx, rx) = oneshot::channel();
10988 let tx = Arc::new(std::sync::Mutex::new(Some(tx)));
10989
10990 bob_transport.expect_send_message().returning(move |_, data| {
10991 let tx = tx.clone();
10992 Box::pin(async move {
10993 if let Ok(HoprStartProtocol::SessionError(err)) =
10994 HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
10995 && let Some(tx) = tx.lock().unwrap().take()
10996 {
10997 let _ = tx.send(err);
10998 }
10999 Ok(())
11000 })
11001 });
11002
11003 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11004 let (new_session_tx, _) = futures::channel::mpsc::channel(1);
11005 mgr.start(bob_sender.clone(), new_session_tx, None, None)?;
11006
11007 let alice_pseudonym = HoprPseudonym::random();
11008
11009 mgr.handle_incoming_session_initiation(
11010 alice_pseudonym,
11011 StartInitiation {
11012 challenge: MIN_CHALLENGE,
11013 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11014 capabilities: HoprSessionCapabilities(Capability::Segmentation.into()),
11015 additional_data: 0,
11016 },
11017 )
11018 .await?;
11019
11020 let err = rx.await.context("send_message was never called")?;
11021 assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
11022 assert_eq!(err.identifier, ErrorIdentifier::Challenge(MIN_CHALLENGE));
11023 assert_eq!(mgr.num_active_sessions(), 0);
11024
11025 bob_sender.close_channel();
11026 bob_handle.await??;
11027 Ok(())
11028 }
11029
11030 /// A node with no `PixToolbox` must refuse an incoming session that asks for `UsePIX`, rather
11031 /// than establish one it cannot run the PIX state machine for.
11032 ///
11033 /// This is the guard at the top of `handle_incoming_session_initiation`, and it was untested:
11034 /// the integration test named for it never negotiated PIX at all, so the absent toolbox was not
11035 /// the operative cause of anything it observed.
11036 ///
11037 /// The offered dimensions have to be ones `check_pix_params` *accepts*, and the test asserts
11038 /// that before exercising the handler. The fallthrough from a rejected `check_pix_params` emits
11039 /// the identical `StartErrorReason::UnacceptablePixParams` under the identical
11040 /// `ErrorIdentifier::Challenge` and creates no session either, so with unacceptable dimensions
11041 /// nothing here could tell the two refusals apart — the guard could be deleted outright and
11042 /// this would still pass.
11043 #[test_log::test(tokio::test)]
11044 async fn incoming_usepix_session_is_rejected_when_no_pix_toolbox_is_installed() -> anyhow::Result<()> {
11045 use std::sync::Arc;
11046
11047 use hopr_protocol_start::{StartErrorReason, StartInitiation};
11048 use tokio::sync::oneshot;
11049
11050 // PIX not enforced, and the default `quota_range` ends exactly on the quota the default
11051 // dimensions imply, so the offer below sits inside it.
11052 let mgr = SessionManager::new(SessionManagerConfig::default());
11053
11054 let mut bob_transport = MockMsgSender::new();
11055 let (tx, rx) = oneshot::channel();
11056 let tx = Arc::new(std::sync::Mutex::new(Some(tx)));
11057
11058 bob_transport.expect_send_message().returning(move |_, data| {
11059 let tx = tx.clone();
11060 Box::pin(async move {
11061 if let Ok(HoprStartProtocol::SessionError(err)) =
11062 HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text)
11063 && let Some(tx) = tx.lock().unwrap().take()
11064 {
11065 let _ = tx.send(err);
11066 }
11067 Ok(())
11068 })
11069 });
11070
11071 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11072 let (new_session_tx, _) = futures::channel::mpsc::channel(1);
11073 // No toolbox — the third argument is what a relay that does not participate in PIX gets.
11074 mgr.start(bob_sender.clone(), new_session_tx, None, None)?;
11075
11076 let req = StartInitiation {
11077 challenge: MIN_CHALLENGE,
11078 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11079 capabilities: HoprSessionCapabilities(Capability::Segmentation | Capability::UsePIX),
11080 additional_data: into_initiation_additional_data(DEFAULT_PIX_PARAMS.into_additional_data(0)),
11081 };
11082
11083 // What makes the missing toolbox the sole remaining cause of the refusal below.
11084 assert!(
11085 decode_and_check(&mgr, &req).is_some(),
11086 "the offered dimensions must be acceptable, or the refusal cannot be attributed to the guard"
11087 );
11088
11089 mgr.handle_incoming_session_initiation(HoprPseudonym::random(), req)
11090 .await?;
11091
11092 let err = rx.await.context("send_message was never called")?;
11093 assert_eq!(err.reason, StartErrorReason::UnacceptablePixParams);
11094 assert_eq!(err.identifier, ErrorIdentifier::Challenge(MIN_CHALLENGE));
11095 assert_eq!(mgr.num_active_sessions(), 0, "no slot may be created for a refusal");
11096
11097 bob_sender.close_channel();
11098 bob_handle.await??;
11099 Ok(())
11100 }
11101
11102 /// Verifies that the exit/responder (Bob) rejects an `SsaCommit` for a session that has no PIX
11103 /// state — i.e., the SSA commit is delivered with a session ID that Bob does not hold.
11104 ///
11105 /// ## Steps
11106 /// 1. Bob's manager is started with a `PixToolbox` and a PIX quota config. Alice's session initiation is processed
11107 /// normally via `handle_incoming_session_initiation`, establishing a session with PIX state.
11108 /// 2. `handle_ssa_commit` is called with a completely different (random) session ID — one that Bob's manager does
11109 /// not have.
11110 /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::NonExistingSession)`,
11111 /// confirming the exit rejects commits for unknown sessions.
11112 #[test_log::test(tokio::test)]
11113 async fn exit_rejects_ssa_commit_when_session_has_no_pix_state() -> anyhow::Result<()> {
11114 use std::collections::HashMap;
11115
11116 use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11117 use hopr_protocol_start::StartInitiation;
11118
11119 let ssa_gen_config = SsaGeneratorConfig {
11120 polynomials_per_ssa: 2,
11121 threshold: 2,
11122 surplus_shares: 1,
11123 };
11124 let ssa_rec_config = SsaReconstructorConfig::default();
11125
11126 let (pix_toolbox, _) = PixToolbox::new(
11127 SsaShareGenerator::new(ssa_gen_config).into(),
11128 SsaReconstructor::new(ssa_rec_config).into(),
11129 );
11130
11131 let mgr = SessionManager::new(SessionManagerConfig {
11132 pix_config: IncomingSessionPixConfig {
11133 quota_range: 0..=1024 * 1024 * 1024,
11134 ..Default::default()
11135 },
11136 ..Default::default()
11137 });
11138
11139 let mut bob_transport = MockMsgSender::new();
11140 bob_transport
11141 .expect_send_message()
11142 .returning(|_, _| Box::pin(async { Ok(()) }));
11143 // handle_incoming_session_initiation sends SessionEstablished + SsaRequest.
11144 bob_transport
11145 .expect_send_message()
11146 .returning(|_, _| Box::pin(async { Ok(()) }));
11147
11148 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11149 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11150 let _notifications = tokio::spawn(async move {
11151 pin_mut!(new_session_rx);
11152 while let Some(_session) = new_session_rx.next().await {}
11153 });
11154 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11155
11156 let alice_pseudonym = HoprPseudonym::random();
11157
11158 mgr.handle_incoming_session_initiation(
11159 alice_pseudonym,
11160 StartInitiation {
11161 challenge: MIN_CHALLENGE,
11162 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11163 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11164 additional_data: small_pix_additional_data(),
11165 },
11166 )
11167 .await?;
11168
11169 let result = mgr
11170 .handle_ssa_commit(
11171 HoprPseudonym::random(),
11172 SsaClientCommitmentMessage {
11173 session_id: alice_pseudonym,
11174 ssa_index: SsaIndex::MIN,
11175 coefficient_index: 0,
11176 commitment_proof: None,
11177 coefficient_commitments: HashMap::new(),
11178 },
11179 )
11180 .await;
11181
11182 bob_sender.close_channel();
11183 bob_handle.await??;
11184
11185 assert!(result.is_err());
11186 assert!(matches!(
11187 result.unwrap_err(),
11188 TransportSessionError::Manager(SessionManagerError::NonExistingSession)
11189 ));
11190
11191 Ok(())
11192 }
11193
11194 /// Verifies that a session is closed on the very first `UnverifiableShares` PIX event.
11195 ///
11196 /// An event means a whole polynomial failed to open its commitment, which already dooms the
11197 /// cycle, so there is nothing to tolerate and nothing to configure. See
11198 /// `SessionPixSupervisor::on_unverifiable_shares` for the reasoning.
11199 ///
11200 /// ## Steps
11201 /// 1. Bob's manager is started with a `PixToolbox` and a PIX quota config. Alice's session initiation is processed
11202 /// via `handle_incoming_session_initiation`.
11203 /// 2. One `UnverifiableShares` event is dispatched for the session's `SsaId`.
11204 /// 3. The session is closed: `active_sessions` is empty and `num_active_sessions` is 0, confirming the supervisor
11205 /// closed it.
11206 #[test_log::test(tokio::test)]
11207 async fn session_is_closed_on_the_first_unverifiable_share() -> anyhow::Result<()> {
11208 use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11209 use hopr_protocol_start::StartInitiation;
11210
11211 let ssa_gen_config = SsaGeneratorConfig {
11212 polynomials_per_ssa: 2,
11213 threshold: 2,
11214 surplus_shares: 1,
11215 };
11216
11217 let (pix_toolbox, _) = PixToolbox::new(
11218 SsaShareGenerator::new(ssa_gen_config).into(),
11219 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11220 );
11221
11222 let mgr = SessionManager::new(SessionManagerConfig {
11223 pix_config: IncomingSessionPixConfig {
11224 quota_range: 0..=1024 * 1024 * 1024,
11225 ..Default::default()
11226 },
11227 ..Default::default()
11228 });
11229
11230 let mut bob_transport = MockMsgSender::new();
11231 bob_transport
11232 .expect_send_message()
11233 .returning(|_, _| Box::pin(async { Ok(()) }));
11234 // handle_incoming_session_initiation sends SessionEstablished + SsaRequest.
11235 bob_transport
11236 .expect_send_message()
11237 .returning(|_, _| Box::pin(async { Ok(()) }));
11238
11239 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11240 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11241 let _notifications = tokio::spawn(async move {
11242 pin_mut!(new_session_rx);
11243 while let Some(_session) = new_session_rx.next().await {}
11244 });
11245 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11246
11247 let alice_pseudonym = HoprPseudonym::random();
11248
11249 mgr.handle_incoming_session_initiation(
11250 alice_pseudonym,
11251 StartInitiation {
11252 challenge: MIN_CHALLENGE,
11253 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11254 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11255 additional_data: small_pix_additional_data(),
11256 },
11257 )
11258 .await?;
11259
11260 assert_eq!(mgr.num_active_sessions(), 1, "the session must start out open");
11261
11262 let ssa_id = SsaId::new(alice_pseudonym, SsaIndex::MIN);
11263 mgr.dispatch_pix_event(HoprSessionInPixEvent::UnverifiableShares {
11264 ssa_id,
11265 observed_total: 1,
11266 peer: crate::supervision::test_peer(),
11267 })
11268 .await?;
11269
11270 // The supervisor decides, and its `Close` reaches the driver over a channel, so the close
11271 // is observed rather than assumed.
11272 tokio::time::timeout(Duration::from_secs(1), async {
11273 while mgr.num_active_sessions() > 0 {
11274 tokio::time::sleep(Duration::from_millis(10)).await;
11275 }
11276 })
11277 .await
11278 .context("session must be closed by the first unverifiable share")?;
11279
11280 assert!(mgr.active_sessions().is_empty());
11281
11282 bob_sender.close_channel();
11283 bob_handle.await??;
11284 Ok(())
11285 }
11286
11287 // Four tests lived here, all asserting how many `SsaRequest` messages an `SsaAlmostRecovered` or
11288 // `SsaRecovered` event produced: `exit_requests_new_ssa_after_almost_recovered_event`,
11289 // `exit_ignores_stale_ssa_almost_recovered_event`,
11290 // `exit_handles_concurrent_almost_and_full_recovery_for_same_ssa` and
11291 // `exit_requests_new_ssa_on_recovery_when_not_already_pipelined`.
11292 //
11293 // That decision is the supervisor's now, and each of them has a direct counterpart against the
11294 // state machine: `almost_recovered_while_recovering_requests_next_once`,
11295 // `almost_recovered_while_awaiting_deposit_defers_request`,
11296 // `recovered_with_prior_early_event_does_not_fallback` and
11297 // `recovered_without_prior_early_event_falls_back_to_request`. Reaching the same logic through
11298 // an event channel, an action channel and a spawned driver only added scheduling noise — and
11299 // three of the four asserted a pipelining that no longer happens on an unfunded SSA, which is
11300 // the deliberate change: the Exit does not commit to another SSA before the current one is paid
11301 // for.
11302 //
11303 // What those tests did cover that the state machine cannot is the wiring itself, in both
11304 // directions. Actions reaching the wire is `the_opening_ssa_request_follows_session_established`
11305 // below; events reaching the supervisor is `session_is_closed_on_the_first_unverifiable_share`.
11306
11307 /// Verifies that the supervisor's opening `RequestSsa` reaches the wire, and does so *after* the
11308 /// `SessionEstablished` that publishes the Session.
11309 ///
11310 /// The ordering is why the supervisor is spawned where it is. An `SsaRequest` arriving first
11311 /// would reference a Session the Entry has not been told exists.
11312 #[test_log::test(tokio::test)]
11313 async fn the_opening_ssa_request_follows_session_established() -> anyhow::Result<()> {
11314 use std::sync::Arc;
11315
11316 use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11317 use hopr_protocol_start::StartInitiation;
11318
11319 let pix_toolbox = pix_toolbox_with_pool(
11320 SsaShareGenerator::new(SsaGeneratorConfig {
11321 polynomials_per_ssa: 2,
11322 threshold: 2,
11323 surplus_shares: 1,
11324 })
11325 .into(),
11326 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11327 );
11328
11329 let mgr = SessionManager::new(SessionManagerConfig {
11330 pix_config: IncomingSessionPixConfig {
11331 quota_range: 0..=1024 * 1024 * 1024,
11332 ..Default::default()
11333 },
11334 ..Default::default()
11335 });
11336
11337 // Records the kind of every outbound Start message, in order.
11338 let sent = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new()));
11339 let sent_clone = sent.clone();
11340 let mut bob_transport = MockMsgSender::new();
11341 bob_transport.expect_send_message().returning(move |_, data| {
11342 let sent_clone = sent_clone.clone();
11343 Box::pin(async move {
11344 match HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text) {
11345 Ok(HoprStartProtocol::SessionEstablished(_)) => sent_clone.lock().unwrap().push("established"),
11346 Ok(HoprStartProtocol::SsaRequest(req)) => {
11347 assert_eq!(
11348 req.dimensions().expect("SsaRequest params must be in range"),
11349 PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE).expect("valid"),
11350 "SsaRequest must carry the negotiated dimensions, surplus included"
11351 );
11352 assert_eq!(
11353 req.commitments.keys().copied().collect::<Vec<_>>(),
11354 [SsaIndex::MIN],
11355 "the opening request must commit to the first SSA index"
11356 );
11357 sent_clone.lock().unwrap().push("ssa_request");
11358 }
11359 _ => {}
11360 }
11361 Ok(())
11362 })
11363 });
11364
11365 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11366 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11367 let _notifications = tokio::spawn(async move {
11368 pin_mut!(new_session_rx);
11369 while let Some(_session) = new_session_rx.next().await {}
11370 });
11371 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11372
11373 let alice_pseudonym = HoprPseudonym::random();
11374 mgr.handle_incoming_session_initiation(
11375 alice_pseudonym,
11376 StartInitiation {
11377 challenge: MIN_CHALLENGE,
11378 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11379 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11380 additional_data: small_pix_additional_data(),
11381 },
11382 )
11383 .await?;
11384
11385 tokio::time::timeout(Duration::from_secs(1), async {
11386 while sent.lock().unwrap().len() < 2 {
11387 tokio::time::sleep(Duration::from_millis(10)).await;
11388 }
11389 })
11390 .await
11391 .context("the supervisor's opening SsaRequest never reached the wire")?;
11392
11393 assert_eq!(
11394 sent.lock().unwrap().as_slice(),
11395 ["established", "ssa_request"],
11396 "the Entry must learn the Session exists before it is asked to commit to an SSA"
11397 );
11398
11399 bob_sender.close_channel();
11400 bob_handle.await??;
11401 Ok(())
11402 }
11403
11404 /// A pool that does not supply deposit data must take the Session with it, and name itself.
11405 ///
11406 /// The data only ever travels in the `SsaRequest` that carries the commitments, so an Entry that
11407 /// needs it and does not get it cannot ask for it later — it would simply never deposit, and the
11408 /// Session would die on the Exit's own commitment deadline, blamed on the peer. Failing here
11409 /// costs one Session that was not going to work, and points at the local pool instead: the Entry
11410 /// is told with `UnacceptablePixParams` rather than the supervisor's generic `Unknown`, and this
11411 /// node records [`ClosureReason::MissingDepositData`] rather than `PixFailure`.
11412 ///
11413 /// The event stream is dropped rather than left unanswered, which reaches the same failure
11414 /// through the listener check instead of [`DEPOSIT_DATA_REQUEST_TIMEOUT`] — the same error, the
11415 /// same close, three seconds sooner. What the collection itself does with a pool that answers
11416 /// badly is covered by the `request_deposit_data` tests below.
11417 #[test_log::test(tokio::test)]
11418 async fn missing_deposit_data_closes_the_session_and_names_the_pool() -> anyhow::Result<()> {
11419 use std::sync::Arc;
11420
11421 use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11422 use hopr_protocol_start::StartInitiation;
11423
11424 // Deliberately *not* `pix_toolbox_with_pool`: nothing answers, which is the case under test.
11425 let (pix_toolbox, pix_events) = PixToolbox::new(
11426 SsaShareGenerator::new(SsaGeneratorConfig {
11427 polynomials_per_ssa: 2,
11428 threshold: 2,
11429 surplus_shares: 1,
11430 })
11431 .into(),
11432 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11433 );
11434 drop(pix_events);
11435
11436 let mgr = SessionManager::new(SessionManagerConfig {
11437 pix_config: IncomingSessionPixConfig {
11438 quota_range: 0..=1024 * 1024 * 1024,
11439 ..Default::default()
11440 },
11441 ..Default::default()
11442 });
11443
11444 // Records the kind of every outbound Start message, with the reason of any SessionError.
11445 let sent = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
11446 let sent_clone = sent.clone();
11447 let mut bob_transport = MockMsgSender::new();
11448 bob_transport.expect_send_message().returning(move |_, data| {
11449 let sent_clone = sent_clone.clone();
11450 Box::pin(async move {
11451 match HoprStartProtocol::decode(data.data.application_tag, &data.data.plain_text) {
11452 Ok(HoprStartProtocol::SessionEstablished(_)) => {
11453 sent_clone.lock().unwrap().push("established".into())
11454 }
11455 Ok(HoprStartProtocol::SsaRequest(_)) => sent_clone.lock().unwrap().push("ssa_request".into()),
11456 Ok(HoprStartProtocol::SessionError(err)) => {
11457 sent_clone.lock().unwrap().push(format!("error:{}", err.reason))
11458 }
11459 _ => {}
11460 }
11461 Ok(())
11462 })
11463 });
11464
11465 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11466 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11467 let _notifications = tokio::spawn(async move {
11468 pin_mut!(new_session_rx);
11469 while let Some(_session) = new_session_rx.next().await {}
11470 });
11471 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11472
11473 let alice_pseudonym = HoprPseudonym::random();
11474 mgr.handle_incoming_session_initiation(
11475 alice_pseudonym,
11476 StartInitiation {
11477 challenge: MIN_CHALLENGE,
11478 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11479 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11480 additional_data: small_pix_additional_data(),
11481 },
11482 )
11483 .await?;
11484
11485 timeout(Duration::from_secs(1), async {
11486 while mgr.num_active_sessions() > 0 {
11487 tokio::time::sleep(Duration::from_millis(10)).await;
11488 }
11489 })
11490 .await
11491 .context("the Session must not survive a pool that cannot supply deposit data")?;
11492
11493 let sent = sent.lock().unwrap().clone();
11494 assert!(
11495 !sent.iter().any(|m| m == "ssa_request"),
11496 "no SsaRequest may go on the wire without the deposit data it has to carry, got {sent:?}"
11497 );
11498 assert!(
11499 sent.iter()
11500 .any(|m| m == &format!("error:{}", StartErrorReason::UnacceptablePixParams)),
11501 "the Entry must be told with a reason that names the failure, got {sent:?}"
11502 );
11503
11504 bob_sender.close_channel();
11505 bob_handle.await??;
11506 Ok(())
11507 }
11508
11509 // `pipelined_ssa_preserves_earlier_deposit_deadline` lived here. It asserted that
11510 // `PixKillSwitch(1)` and `PixKillSwitch(2)` were independent entries in the `AbortableList`,
11511 // which is no longer a thing the manager can observe: per-SSA deadlines belong to the
11512 // supervisor. The invariant moved with them, to
11513 // `supervision::supervisor::tests::pipelining_a_second_ssa_leaves_the_first_ones_deadlines_alone`,
11514 // where it can compare the deadline instants rather than just the presence of two handles.
11515
11516 /// Verifies that an explicit `close_session` leaves no reconstructor state behind for an SSA
11517 /// cycle that was still in flight.
11518 ///
11519 /// Retirement used to be an explicit sweep over every index the Session had ever used; it is
11520 /// now a consequence of aborting the action driver, which drops the guards it holds. This
11521 /// exercises one cycle because that is all a mock transport can get in flight — pipelining a
11522 /// second needs a funded first, and funding arrives from the chain. The multi-cycle case is
11523 /// covered where the guards are: `protocols/pix`'s guard tests and the supervisor's own suite.
11524 #[test_log::test(tokio::test)]
11525 async fn close_session_retires_in_flight_ssa_cycles() -> anyhow::Result<()> {
11526 let pix_toolbox = pix_toolbox_with_pool(
11527 SsaShareGenerator::new(SsaGeneratorConfig {
11528 polynomials_per_ssa: 2,
11529 threshold: 2,
11530 surplus_shares: 1,
11531 })
11532 .into(),
11533 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11534 );
11535 let mgr = SessionManager::new(SessionManagerConfig {
11536 pix_config: IncomingSessionPixConfig {
11537 quota_range: 0..=1024 * 1024 * 1024,
11538 ..Default::default()
11539 },
11540 ..Default::default()
11541 });
11542 let mut bob_transport = MockMsgSender::new();
11543 bob_transport
11544 .expect_send_message()
11545 .returning(|_, _| Box::pin(async { Ok(()) }));
11546 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11547 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11548 let _notifications = tokio::spawn(async move {
11549 pin_mut!(new_session_rx);
11550 while let Some(_session) = new_session_rx.next().await {}
11551 });
11552 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11553
11554 let alice_pseudonym = HoprPseudonym::random();
11555 mgr.handle_incoming_session_initiation(
11556 alice_pseudonym,
11557 StartInitiation {
11558 challenge: MIN_CHALLENGE,
11559 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11560 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11561 additional_data: small_pix_additional_data(),
11562 },
11563 )
11564 .await?;
11565
11566 // Grab a reference to the reconstructor before close_session consumes the slot.
11567 let pix_toolbox_ref = mgr.pix_toolbox.get().unwrap().clone();
11568 let share_processor = pix_toolbox_ref.share_processor;
11569
11570 let ssa1 = SsaId::new(alice_pseudonym, SsaIndex::MIN);
11571
11572 // Precondition: the first cycle's builder exists, so the assertion below proves actual
11573 // retirement rather than the absence of a builder that was never created. The supervisor's
11574 // opening `RequestSsa` reaches the driver asynchronously, so this waits rather than assumes.
11575 tokio::time::timeout(Duration::from_secs(1), async {
11576 while !share_processor.contains_builder(&ssa1) {
11577 tokio::time::sleep(Duration::from_millis(10)).await;
11578 }
11579 })
11580 .await
11581 .context("the opening SSA request never registered a builder")?;
11582
11583 mgr.close_session(&alice_pseudonym);
11584
11585 // Aborting the driver drops the `SsaCommitmentGuard`s it owns, which releases each SSA's
11586 // reconstructor state. The abort takes effect at the next scheduling point, hence the wait.
11587 tokio::time::timeout(Duration::from_secs(1), async {
11588 while share_processor.contains_builder(&ssa1) {
11589 tokio::time::sleep(Duration::from_millis(10)).await;
11590 }
11591 })
11592 .await
11593 .context("close_session left the in-flight SSA's reconstructor state behind")?;
11594
11595 bob_sender.close_channel();
11596 bob_handle.await??;
11597 Ok(())
11598 }
11599
11600 /// Verifies that the entry/initiator (Alice) rejects a `SsaRequest` from the exit when the
11601 /// proposed SSA quota does not match what Alice announced from her own installed generator.
11602 ///
11603 /// ## Steps
11604 /// 1. Bob's manager is started with a `PixToolbox` and a generous PIX quota config. Alice's session initiation is
11605 /// processed with `additional_data = (polynomials=2, shares=2)`.
11606 /// 2. `handle_ssa_request` is called with a mismatched quota: `(server_polynomials=10, server_shares=10)` while
11607 /// Alice offered `(2, 2)`.
11608 /// 3. The call returns an error matching `TransportSessionError::Manager(SessionManagerError::Unacceptable(_))`,
11609 /// confirming the quota mismatch was detected and rejected.
11610 #[test_log::test(tokio::test)]
11611 async fn entry_rejects_ssa_request_with_mismatched_quota() -> anyhow::Result<()> {
11612 use std::collections::BTreeMap;
11613
11614 use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
11615 use hopr_protocol_start::StartInitiation;
11616
11617 let ssa_gen_config = SsaGeneratorConfig {
11618 polynomials_per_ssa: 2,
11619 threshold: 2,
11620 surplus_shares: 1,
11621 };
11622
11623 let (pix_toolbox, _) = PixToolbox::new(
11624 SsaShareGenerator::new(ssa_gen_config).into(),
11625 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
11626 );
11627
11628 let mgr = SessionManager::new(SessionManagerConfig {
11629 pix_config: IncomingSessionPixConfig {
11630 quota_range: 0..=1024 * 1024 * 1024,
11631 ..Default::default()
11632 },
11633 ..Default::default()
11634 });
11635
11636 let mut bob_transport = MockMsgSender::new();
11637 bob_transport
11638 .expect_send_message()
11639 .returning(|_, _| Box::pin(async { Ok(()) }));
11640 // handle_incoming_session_initiation sends SessionEstablished + SsaRequest.
11641 bob_transport
11642 .expect_send_message()
11643 .returning(|_, _| Box::pin(async { Ok(()) }));
11644
11645 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11646 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11647 let _notifications = tokio::spawn(async move {
11648 pin_mut!(new_session_rx);
11649 while let Some(_session) = new_session_rx.next().await {}
11650 });
11651 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11652
11653 let alice_pseudonym = HoprPseudonym::random();
11654
11655 mgr.handle_incoming_session_initiation(
11656 alice_pseudonym,
11657 StartInitiation {
11658 challenge: MIN_CHALLENGE,
11659 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11660 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11661 additional_data: small_pix_additional_data(),
11662 },
11663 )
11664 .await?;
11665
11666 let session_id = alice_pseudonym;
11667
11668 // Server sends dimensions of (10, 10) while we offered (2, 2) — should be rejected.
11669 let result = mgr
11670 .handle_ssa_request(
11671 alice_pseudonym,
11672 SsaServerCommitmentMessage::new(
11673 session_id,
11674 PixParams::try_new(10, 10, 0, LOCAL_PIX_SUITE)?,
11675 BTreeMap::new(),
11676 [],
11677 ),
11678 )
11679 .await;
11680
11681 bob_sender.close_channel();
11682 bob_handle.await??;
11683
11684 assert!(result.is_err());
11685 assert!(matches!(
11686 result.unwrap_err(),
11687 TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
11688 ));
11689
11690 Ok(())
11691 }
11692
11693 /// The Entry must not admit a successor before a default Exit could possibly have produced its
11694 /// early-recovery request.
11695 ///
11696 /// Recovery is counted in completed polynomials, while emission is windowed. Every fully drained
11697 /// window before the one containing the 85% boundary has already emitted both threshold and
11698 /// surplus shares. Dividing 85% by the global 1.25x surplus factor therefore underestimates the
11699 /// boundary: at the deployed dimensions the first possible early signal is about 87% through
11700 /// emission, not 68%.
11701 ///
11702 /// The gate compares against `min_emission_for_early_recovery` directly, so this test recomputes
11703 /// the boundary independently — from the same first principles, but without calling the function
11704 /// under test — and checks the two agree. A shared helper would agree with itself.
11705 #[test]
11706 fn successor_gate_waits_until_default_early_recovery_can_be_reached() {
11707 let params = DEFAULT_PIX_PARAMS;
11708 let polys = params.polys_per_ssa() as u64;
11709 let threshold = params.shares_per_poly() as u64;
11710 let surplus = params.surplus_shares() as u64;
11711 let window = (hopr_protocol_pix::SHARE_EMISSION_WINDOW as u64).min(polys);
11712 // The floor, because that is what the gate is computed at — see
11713 // `MIN_EARLY_RECOVERY_THRESHOLD`. The default must not sit below it, or a stock Exit would
11714 // ask before a stock Entry admits.
11715 let early = hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD;
11716 assert!(
11717 SsaReconstructorConfig::default().early_recovery_threshold >= early,
11718 "the shipped default must be admissible under the protocol floor it is gated against"
11719 );
11720 let needed = (early * polys as f64).ceil() as u64;
11721
11722 // `needed` lies in this window. Earlier windows are fully exhausted, including surplus;
11723 // inside this one, `threshold - 1` complete passes and `in_window` shares of the threshold
11724 // pass are the absolute minimum that can have been emitted before those polynomials recover.
11725 let prior_windows = (needed - 1) / window;
11726 let prior_polys = prior_windows * window;
11727 let current_width = window.min(polys - prior_polys);
11728 let in_window = needed - prior_polys;
11729 let minimum_emitted = prior_polys * (threshold + surplus) + (threshold - 1) * current_width + in_window;
11730 let minimum_fraction = minimum_emitted as f64 / (polys * (threshold + surplus)) as f64;
11731
11732 let gate = hopr_protocol_pix::min_emission_for_early_recovery(¶ms, early);
11733 assert_eq!(
11734 minimum_emitted,
11735 gate,
11736 "the successor gate opens at {} shares ({:.1}% of emission) against an independently derived earliest \
11737 honest request of {minimum_emitted} ({:.1}%); any shortfall permits a hostile Exit to solicit a deposit \
11738 before it is earned",
11739 gate,
11740 gate as f64 / (polys * (threshold + surplus)) as f64 * 100.0,
11741 minimum_fraction * 100.0,
11742 );
11743
11744 // And the estimate this replaced is demonstrably below it, so the test would have caught it.
11745 assert!(
11746 ((early / 1.5) * (polys * (threshold + surplus)) as f64) < gate as f64,
11747 "the surplus-factor estimate must sit below the real boundary"
11748 );
11749 }
11750
11751 /// Dimensions wide enough that the protocol floor and a stricter local threshold disagree.
11752 ///
11753 /// At `small_pix_params`' two polynomials, `ceil(0.85 x 2)` and `ceil(1.0 x 2)` are both 2, so
11754 /// every threshold in range produces the same boundary and a test built on them cannot tell which
11755 /// one the gate used. Eight separates them: 7 polynomials against 8.
11756 fn wide_pix_params() -> PixParams {
11757 PixParams::try_new(8, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE).expect("test dimensions must be valid")
11758 }
11759
11760 /// Builds an Entry-side manager serving `params`, with the reconstructor at `early_threshold`.
11761 ///
11762 /// Returns the manager, its generator, and the PIX event stream, with one PIX Session already
11763 /// established for the returned pseudonym.
11764 #[allow(clippy::type_complexity)]
11765 async fn entry_with_pix_session(
11766 params: PixParams,
11767 early_threshold: f64,
11768 ) -> anyhow::Result<(
11769 SessionManager<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>,
11770 Arc<SsaShareGenerator<HoprPixSpec>>,
11771 HoprPseudonym,
11772 impl futures::Stream<Item = HoprSessionOutPixEvent> + Unpin,
11773 UnboundedSender<(DestinationRouting, ApplicationDataOut)>,
11774 tokio::task::JoinHandle<anyhow::Result<()>>,
11775 )> {
11776 use hopr_protocol_start::StartInitiation;
11777
11778 let generator = Arc::new(SsaShareGenerator::new(SsaGeneratorConfig {
11779 polynomials_per_ssa: params.polys_per_ssa(),
11780 threshold: params.shares_per_poly(),
11781 surplus_shares: params.surplus_shares(),
11782 }));
11783 let (pix_toolbox, pix_events) = PixToolbox::new(
11784 generator.clone(),
11785 SsaReconstructor::new(SsaReconstructorConfig {
11786 early_recovery_threshold: early_threshold,
11787 ..Default::default()
11788 })
11789 .into(),
11790 );
11791 // This fixture's manager plays both halves, so its own Exit side opens a cycle and blocks on
11792 // the deposit pool. Answered on the way through, leaving the stream the caller reads — which
11793 // is the deposits — as it was.
11794 let pix_events = crate::testing::answering_deposit_pool(pix_events, |_| Vec::new());
11795
11796 let mgr = SessionManager::new(SessionManagerConfig {
11797 pix_config: IncomingSessionPixConfig {
11798 quota_range: 0..=1024 * 1024 * 1024,
11799 ..Default::default()
11800 },
11801 ..Default::default()
11802 });
11803
11804 let mut bob_transport = MockMsgSender::new();
11805 bob_transport
11806 .expect_send_message()
11807 .returning(|_, _| Box::pin(async { Ok(()) }));
11808 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
11809 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
11810 tokio::spawn(async move {
11811 pin_mut!(new_session_rx);
11812 while let Some(_session) = new_session_rx.next().await {}
11813 });
11814 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
11815
11816 let pseudonym = HoprPseudonym::random();
11817 mgr.handle_incoming_session_initiation(
11818 pseudonym,
11819 StartInitiation {
11820 challenge: MIN_CHALLENGE,
11821 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
11822 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
11823 additional_data: into_initiation_additional_data(params.into_additional_data(0)),
11824 },
11825 )
11826 .await?;
11827
11828 Ok((mgr, generator, pseudonym, pix_events, bob_sender, bob_handle))
11829 }
11830
11831 /// One SSA commitment at `index`, from the identity element the Exit-side point is not checked
11832 /// against here.
11833 fn ssa_request_for(index: u32) -> std::collections::BTreeMap<SsaIndex, HoprPixGroupElement> {
11834 let identity = HoprPixGroupElement::try_from(
11835 hopr_protocol_pix::PixGroup::<HoprPixSpec>::default()
11836 .to_bytes()
11837 .as_ref(),
11838 )
11839 .expect("identity element must be valid");
11840 std::collections::BTreeMap::from([(SsaIndex::new(index).expect("non-zero"), identity)])
11841 }
11842
11843 /// Drains whatever the PIX event stream has ready, counting the deposits among it.
11844 ///
11845 /// Only [`ReadyToDeposit`](HoprSessionOutPixEvent::ReadyToDeposit) is counted: an Exit sharing
11846 /// this stream also asks its pool for deposit data on it, and every caller here is asking how
11847 /// many cycles were opened, not how many messages went by.
11848 async fn drain_pix_events(events: &mut (dyn futures::Stream<Item = HoprSessionOutPixEvent> + Unpin)) -> usize {
11849 let mut seen = 0;
11850 // A bounded wait rather than `now_or_never`: these fixtures answer the Exit half's
11851 // deposit-data requests on a forwarding task, so an event that has already been produced may
11852 // not have crossed it yet. The wait is only paid once, when the stream has gone quiet.
11853 while let Ok(Some(event)) = timeout(Duration::from_millis(50), events.next()).await {
11854 if matches!(event, HoprSessionOutPixEvent::ReadyToDeposit(_)) {
11855 seen += 1;
11856 }
11857 }
11858 seen
11859 }
11860
11861 /// Credits `count` Exit → Entry packets to the Session, as its receive path would.
11862 ///
11863 /// These fixtures build their slot through `handle_incoming_session_initiation`, whose receive
11864 /// path counts the *outgoing* direction, so the Entry-side counter the successor gate reads has
11865 /// to be driven by hand. `returned_packets_are_counted_on_the_entry_receive_path` is what pins
11866 /// that a real Entry Session advances it; here it only has to move.
11867 fn credit_returned_packets<S>(mgr: &SessionManager<S>, pseudonym: &HoprPseudonym, count: u64)
11868 where
11869 S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Clone + Send + Sync + Unpin + 'static,
11870 S::Error: std::error::Error + Send + Sync + 'static,
11871 {
11872 mgr.sessions
11873 .get(pseudonym)
11874 .expect("session must exist")
11875 .returned_packets
11876 .fetch_add(count, std::sync::atomic::Ordering::Relaxed);
11877 }
11878
11879 /// Packets the successor gate requires before it will admit a batch beyond `watermark`.
11880 ///
11881 /// Mirrors `handle_ssa_request`'s arithmetic rather than calling it, so that a change to the
11882 /// boundary has to be restated here — a helper shared with the code under test would agree with
11883 /// itself whatever either of them did.
11884 fn required_returned_packets(params: &PixParams, watermark: u32) -> u64 {
11885 let min_emitted =
11886 hopr_protocol_pix::min_emission_for_early_recovery(params, hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD);
11887 let shares_per_cycle = params.polys_per_ssa() as u64 * params.emitted_shares_per_poly() as u64;
11888 let target = (watermark as u64 - 1) * shares_per_cycle + min_emitted;
11889 (target as u128 * params.shares_per_poly() as u128 / params.emitted_shares_per_poly() as u128) as u64
11890 }
11891
11892 /// The successor gate must open at the protocol floor, not at the local reconstructor's threshold.
11893 ///
11894 /// The value that decides when a correct Exit asks for its next batch is the *peer's*
11895 /// `early_recovery_threshold`, and it does not travel on the wire. Deriving the boundary from the
11896 /// Entry's own copy meant two individually valid configurations could not communicate: an Exit
11897 /// configured lower sent its one-shot request before the gate opened, the Entry dropped it
11898 /// silently, and the Session died on `max_ssa_delivery_time` waiting for a commitment that had
11899 /// been deliberately refused — a failure visible on neither node as a configuration error.
11900 ///
11901 /// So the gate is computed at [`hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD`], which
11902 /// `validate_pix_supervision` holds every Exit to. This fixture puts the local reconstructor at
11903 /// `1.0` — the strictest end of the range — and asks at exactly the floor's boundary. A gate built
11904 /// on the local value wants one more share and refuses.
11905 #[test_log::test(tokio::test)]
11906 async fn successor_gate_opens_at_the_protocol_floor_not_the_local_threshold() -> anyhow::Result<()> {
11907 let params = wide_pix_params();
11908 let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
11909 entry_with_pix_session(params, 1.0).await?;
11910
11911 mgr.handle_ssa_request(
11912 pseudonym,
11913 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
11914 )
11915 .await
11916 .context("the opening batch must be accepted")?;
11917 assert_eq!(1, drain_pix_events(&mut pix_events).await);
11918
11919 // Fully served, so that the emission boundary is the only thing this test can be measuring.
11920 credit_returned_packets(&mgr, &pseudonym, required_returned_packets(¶ms, 1));
11921
11922 let at_floor = hopr_protocol_pix::min_emission_for_early_recovery(
11923 ¶ms,
11924 hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD,
11925 );
11926 let at_local = hopr_protocol_pix::min_emission_for_early_recovery(¶ms, 1.0);
11927 assert!(
11928 at_floor < at_local,
11929 "the fixture must separate the two boundaries, got {at_floor} and {at_local}"
11930 );
11931
11932 // One share short of the floor's boundary, the request is early under either reading.
11933 for sent in 1..at_floor as u32 {
11934 generator.next_share(&pseudonym, &sent.to_be_bytes())?;
11935 }
11936 let err = mgr
11937 .handle_ssa_request(
11938 pseudonym,
11939 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
11940 )
11941 .await
11942 .expect_err("a request one share below the boundary must be refused");
11943 assert!(
11944 matches!(
11945 err,
11946 TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
11947 ),
11948 "expected Unacceptable, got {err:?}"
11949 );
11950 assert_eq!(0, drain_pix_events(&mut pix_events).await);
11951
11952 // On the boundary itself it is admitted — and would not be if the gate read the local 1.0.
11953 generator.next_share(&pseudonym, &at_floor.to_be_bytes())?;
11954 assert_eq!(
11955 at_floor,
11956 generator
11957 .emission_progress(&pseudonym)
11958 .expect("committed")
11959 .front_emitted,
11960 "the fixture must stand exactly on the floor's boundary"
11961 );
11962 mgr.handle_ssa_request(
11963 pseudonym,
11964 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
11965 )
11966 .await
11967 .context("a request at the protocol floor must be accepted whatever the local threshold is")?;
11968 assert_eq!(1, drain_pix_events(&mut pix_events).await);
11969
11970 bob_sender.close_channel();
11971 bob_handle.await??;
11972 Ok(())
11973 }
11974
11975 /// A successor request arriving after the generator's state was discarded must be refused.
11976 ///
11977 /// The gate only applies where there is emission progress to measure, and absent progress is
11978 /// otherwise the ordinary opening batch. But the generator keeps its per-pseudonym state in a
11979 /// cache with an idle retention refreshed by share *emission*, so a Session kept alive while the
11980 /// Entry emits nothing outlives it — and the successor gate is then not relaxed but deleted, along
11981 /// with the monotonic index the discarded entry carried. An Exit that arranges that farms one
11982 /// unearned deposit per retention period, which is the exposure the gate exists to close.
11983 ///
11984 /// `forget` reaches the same state the retention would, deliberately: an evicted entry and an
11985 /// explicitly dropped one are indistinguishable from the outside, which is precisely why the fact
11986 /// that a batch *was* committed has to be held somewhere that lives as long as the Session.
11987 ///
11988 /// The opening request in this test is the other half of the property — absent state with nothing
11989 /// committed is still admitted, or no PIX Session could ever start.
11990 #[test_log::test(tokio::test)]
11991 async fn an_ssa_request_against_discarded_generator_state_is_refused_and_closes_the_session() -> anyhow::Result<()>
11992 {
11993 let params = small_pix_params();
11994 let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
11995 entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
11996
11997 mgr.handle_ssa_request(
11998 pseudonym,
11999 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12000 )
12001 .await
12002 .context("the opening batch must be accepted")?;
12003 assert_eq!(1, drain_pix_events(&mut pix_events).await);
12004 assert_eq!(vec![pseudonym], mgr.active_sessions());
12005
12006 generator.forget(&pseudonym);
12007 assert!(
12008 generator.emission_progress(&pseudonym).is_none(),
12009 "the fixture must reproduce the state an idle eviction leaves behind"
12010 );
12011
12012 let err = mgr
12013 .handle_ssa_request(
12014 pseudonym,
12015 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12016 )
12017 .await
12018 .expect_err("a successor request against discarded generator state must be refused");
12019 assert!(
12020 matches!(
12021 err,
12022 TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12023 ),
12024 "expected Unacceptable, got {err:?}"
12025 );
12026 assert_eq!(
12027 0,
12028 drain_pix_events(&mut pix_events).await,
12029 "a refused request must not emit a single ReadyToDeposit"
12030 );
12031 assert!(
12032 mgr.active_sessions().is_empty(),
12033 "the Entry can no longer emit shares for the cycles it committed to, so leaving the Session open only \
12034 makes the Exit wait out its commitment deadline"
12035 );
12036
12037 bob_sender.close_channel();
12038 bob_handle.await??;
12039 Ok(())
12040 }
12041
12042 /// Emission is not service: a successor must be refused until the Exit has returned data.
12043 ///
12044 /// This is H6, and it is the case the emission half of the gate cannot see. `emission_progress`
12045 /// counts shares this node handed to its own packet pipeline — a consumption `create_surb_for_path`
12046 /// does not even roll back when the rest of the packet build fails. An Exit that requests, is
12047 /// funded and then returns nothing still walks that counter forward for as long as the Entry keeps
12048 /// sending, so on its own it prices deposits against work the Entry did to itself.
12049 ///
12050 /// The fixture therefore satisfies the emission half completely and the returned half not at all.
12051 /// Before this gate existed the request below was admitted and a second deposit instruction went
12052 /// out for it.
12053 #[test_log::test(tokio::test)]
12054 async fn entry_refuses_a_successor_the_exit_has_not_paid_for_with_returned_data() -> anyhow::Result<()> {
12055 let params = wide_pix_params();
12056 let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12057 entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12058
12059 mgr.handle_ssa_request(
12060 pseudonym,
12061 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12062 )
12063 .await
12064 .context("the opening batch must be accepted")?;
12065 assert_eq!(1, drain_pix_events(&mut pix_events).await);
12066
12067 // Emit the whole cycle: the emission half of the gate is now satisfied several times over,
12068 // and it is the only half that would have been consulted before.
12069 for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12070 generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12071 }
12072 let progress = generator.emission_progress(&pseudonym).expect("committed");
12073 assert!(
12074 progress.is_serving_last_committed()
12075 && progress.front_emitted
12076 >= hopr_protocol_pix::min_emission_for_early_recovery(
12077 ¶ms,
12078 hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD
12079 ),
12080 "the fixture must satisfy the emission half outright, or it is not testing the other one"
12081 );
12082
12083 let err = mgr
12084 .handle_ssa_request(
12085 pseudonym,
12086 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12087 )
12088 .await
12089 .expect_err("a successor must not be funded before the Exit has returned anything");
12090 assert!(
12091 matches!(
12092 err,
12093 TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12094 ),
12095 "expected Unacceptable, got {err:?}"
12096 );
12097 assert_eq!(
12098 0,
12099 drain_pix_events(&mut pix_events).await,
12100 "a refused successor must not emit a single ReadyToDeposit"
12101 );
12102 assert_eq!(
12103 vec![pseudonym],
12104 mgr.active_sessions(),
12105 "an under-served request is refused, not fatal — the Exit may still earn the batch it holds"
12106 );
12107
12108 // And the same request is admitted the moment the service exists.
12109 credit_returned_packets(&mgr, &pseudonym, required_returned_packets(¶ms, 1));
12110 mgr.handle_ssa_request(
12111 pseudonym,
12112 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12113 )
12114 .await
12115 .context("a successor must be admitted once the Exit has returned what it owes")?;
12116 assert_eq!(1, drain_pix_events(&mut pix_events).await);
12117
12118 bob_sender.close_channel();
12119 bob_handle.await??;
12120 Ok(())
12121 }
12122
12123 /// An Exit that has returned nothing is refused immediately, not held.
12124 ///
12125 /// The deferral below the boundary exists for reordering, and it holds a bounded
12126 /// `for_each_concurrent` slot and the per-pseudonym request lock while it runs. That is only safe
12127 /// because it is entered exclusively for a *near miss*: an Exit which has returned nothing at all
12128 /// must cost nothing at all to refuse, or "ask early and say nothing" becomes a way to park one of
12129 /// this node's Start-protocol slots per Session it holds.
12130 #[test_log::test(tokio::test)]
12131 async fn an_unserved_successor_is_refused_without_waiting() -> anyhow::Result<()> {
12132 let params = wide_pix_params();
12133 let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12134 entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12135
12136 mgr.handle_ssa_request(
12137 pseudonym,
12138 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12139 )
12140 .await
12141 .context("the opening batch must be accepted")?;
12142 assert_eq!(1, drain_pix_events(&mut pix_events).await);
12143
12144 for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12145 generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12146 }
12147
12148 let started = std::time::Instant::now();
12149 let err = mgr
12150 .handle_ssa_request(
12151 pseudonym,
12152 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12153 )
12154 .await
12155 .expect_err("an unserved successor must be refused");
12156 let elapsed = started.elapsed();
12157
12158 assert!(
12159 matches!(
12160 err,
12161 TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12162 ),
12163 "expected Unacceptable, got {err:?}"
12164 );
12165 assert!(
12166 elapsed * 4 < SSA_SUCCESSOR_SERVICE_WAIT,
12167 "refusing an Exit that returned nothing took {elapsed:?}, which is within reach of the \
12168 {SSA_SUCCESSOR_SERVICE_WAIT:?} near-miss wait — it must not have entered it at all"
12169 );
12170
12171 bob_sender.close_channel();
12172 bob_handle.await??;
12173 Ok(())
12174 }
12175
12176 /// A request that arrives just ahead of the packets that earned it is waited for, not refused.
12177 ///
12178 /// A conforming Exit asks the instant its reconstructor crosses the threshold, and that request
12179 /// travels the same mixed path as the returned packets which unlocked the shares — so it can
12180 /// overtake the last few of them. Refusing it is not a mild outcome: `RequestSsa` is emitted once
12181 /// per index and never retried, so the Exit sits in `AwaitingCommitment` until
12182 /// `max_ssa_delivery_time` and then closes the Session as `CommitmentTimeout`. The gate must
12183 /// therefore absorb the reordering rather than treat it as under-service.
12184 #[test_log::test(tokio::test)]
12185 async fn a_near_miss_successor_waits_for_the_packets_still_in_flight() -> anyhow::Result<()> {
12186 let params = wide_pix_params();
12187 let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12188 entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12189
12190 mgr.handle_ssa_request(
12191 pseudonym,
12192 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12193 )
12194 .await
12195 .context("the opening batch must be accepted")?;
12196 assert_eq!(1, drain_pix_events(&mut pix_events).await);
12197
12198 for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12199 generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12200 }
12201
12202 // One packet short: the request is in flight ahead of the last one that earned it.
12203 let required = required_returned_packets(¶ms, 1);
12204 assert!(required > 1, "the fixture needs room to be one short");
12205 credit_returned_packets(&mgr, &pseudonym, required - 1);
12206
12207 // The straggler lands while the request is already being evaluated.
12208 let mgr_clone = mgr.clone();
12209 let late = tokio::spawn(async move {
12210 tokio::time::sleep(SSA_SUCCESSOR_SERVICE_POLL).await;
12211 credit_returned_packets(&mgr_clone, &pseudonym, 1);
12212 });
12213
12214 mgr.handle_ssa_request(
12215 pseudonym,
12216 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12217 )
12218 .await
12219 .context("a request one packet ahead of its own service must be waited for, not refused")?;
12220 assert_eq!(1, drain_pix_events(&mut pix_events).await);
12221 late.await?;
12222
12223 bob_sender.close_channel();
12224 bob_handle.await??;
12225 Ok(())
12226 }
12227
12228 /// Service rendered before the first commitment does not pay for the first successor.
12229 ///
12230 /// Until a cycle is committed the generator holds no polynomials, so the SURBs going out carry no
12231 /// shares — and the Exit may legitimately be served up to `max_predeposit_packets` of them before
12232 /// any deposit exists. Crediting that prefix would let an Exit bank unpaid service against the
12233 /// first cycle it *is* paid for, which is the one window in a Session's life where the gate has no
12234 /// prior cycle to measure against.
12235 #[test_log::test(tokio::test)]
12236 async fn service_returned_before_the_first_commitment_is_not_credited() -> anyhow::Result<()> {
12237 let params = wide_pix_params();
12238 let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12239 entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12240
12241 // Generously served *before* anything is committed, and none of it may count.
12242 let required = required_returned_packets(¶ms, 1);
12243 credit_returned_packets(&mgr, &pseudonym, required * 10);
12244
12245 mgr.handle_ssa_request(
12246 pseudonym,
12247 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12248 )
12249 .await
12250 .context("the opening batch must be accepted")?;
12251 assert_eq!(1, drain_pix_events(&mut pix_events).await);
12252
12253 for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12254 generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12255 }
12256
12257 let err = mgr
12258 .handle_ssa_request(
12259 pseudonym,
12260 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12261 )
12262 .await
12263 .expect_err("pre-commitment service must not pay for the first successor");
12264 assert!(
12265 matches!(
12266 err,
12267 TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12268 ),
12269 "expected Unacceptable, got {err:?}"
12270 );
12271 assert_eq!(0, drain_pix_events(&mut pix_events).await);
12272
12273 bob_sender.close_channel();
12274 bob_handle.await??;
12275 Ok(())
12276 }
12277
12278 /// The boundary is discounted by exactly the loss the surplus insures against.
12279 ///
12280 /// The Exit unlocks a share when the *first relayer* acknowledges its returned packet, which is
12281 /// upstream of the Entry — so everything lost after that point is progress the Exit legitimately
12282 /// has and this node cannot observe. Demanding the undiscounted emission boundary would refuse
12283 /// conforming Exits on any lossy path; an Exit losing more than the surplus covers could not have
12284 /// reconstructed the cycle at all, so the surplus ratio is the honest allowance.
12285 ///
12286 /// Pinned at the deployed dimensions because the resulting fraction is not something a reader can
12287 /// check by inspection, and it is what decides when real money moves.
12288 #[test]
12289 fn the_successor_boundary_is_discounted_by_the_surplus_it_insures() -> anyhow::Result<()> {
12290 use crate::{DEFAULT_PIX_POLYS_PER_SSA, DEFAULT_PIX_SHARES_PER_POLY, DEFAULT_PIX_SURPLUS_SHARES};
12291
12292 let params = PixParams::try_new(
12293 DEFAULT_PIX_POLYS_PER_SSA,
12294 DEFAULT_PIX_SHARES_PER_POLY,
12295 DEFAULT_PIX_SURPLUS_SHARES,
12296 LOCAL_PIX_SUITE,
12297 )?;
12298 let cycle = params.polys_per_ssa() as u64 * params.emitted_shares_per_poly() as u64;
12299 assert_eq!(655_360, cycle);
12300
12301 let undiscounted = hopr_protocol_pix::min_emission_for_early_recovery(
12302 ¶ms,
12303 hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD,
12304 );
12305 assert_eq!(569_140, undiscounted);
12306
12307 let required = required_returned_packets(¶ms, 1);
12308 assert_eq!(455_312, required);
12309 assert_eq!(
12310 undiscounted * DEFAULT_PIX_SHARES_PER_POLY as u64
12311 / (DEFAULT_PIX_SHARES_PER_POLY + DEFAULT_PIX_SURPLUS_SHARES) as u64,
12312 required,
12313 "the discount must be the surplus ratio and nothing else"
12314 );
12315
12316 let fraction = required as f64 / cycle as f64;
12317 assert!(
12318 (0.694..0.696).contains(&fraction),
12319 "the deployed successor boundary must sit at ~69.5% of a cycle's returned packets, got {fraction}"
12320 );
12321
12322 // Each further cycle of a batch is demanded in full, at the same discount.
12323 assert_eq!(
12324 required
12325 + cycle * DEFAULT_PIX_SHARES_PER_POLY as u64
12326 / (DEFAULT_PIX_SHARES_PER_POLY + DEFAULT_PIX_SURPLUS_SHARES) as u64,
12327 required_returned_packets(¶ms, 2),
12328 "a batch of two must demand the first cycle's whole discounted worth plus the second's boundary"
12329 );
12330
12331 Ok(())
12332 }
12333
12334 /// A real Entry Session must advance the counter the successor gate reads.
12335 ///
12336 /// The gate's worst failure is not being too strict, it is being wired to a counter nothing
12337 /// feeds: that closes it permanently, and every PIX Session then dies on its second cycle with
12338 /// the Exit blaming its own commitment deadline. The other tests here drive
12339 /// `returned_packets` by hand, so none of them would notice.
12340 ///
12341 /// Deliberately built with `surb_management: None`. That is the branch which passed `session_rx`
12342 /// through untouched and left `surb_estimator` at its default — the one place where reusing the
12343 /// balancer's estimate instead of a counter of our own would have produced exactly that silent
12344 /// permanent closure.
12345 #[test_log::test(tokio::test)]
12346 async fn returned_packets_are_counted_on_the_entry_receive_path() -> anyhow::Result<()> {
12347 use hopr_protocol_start::StartProtocolDiscriminants;
12348
12349 let alice_pseudonym = HoprPseudonym::random();
12350 let bob_peer: Address = (&ChainKeypair::random()).into();
12351
12352 let alice_mgr = SessionManager::new(Default::default());
12353 let bob_mgr = SessionManager::new(Default::default());
12354
12355 let mut alice_transport = MockMsgSender::new();
12356 let mut bob_transport = MockMsgSender::new();
12357
12358 // Alice → Bob, and Bob → Alice, with no filtering: the handshake is not what is under test.
12359 let bob_mgr_clone = bob_mgr.clone();
12360 alice_transport.expect_send_message().returning(move |_, data| {
12361 let bob_mgr_clone = bob_mgr_clone.clone();
12362 Box::pin(async move {
12363 let _ = bob_mgr_clone.dispatch_message(
12364 alice_pseudonym,
12365 ApplicationDataIn {
12366 data: data.data,
12367 packet_info: Default::default(),
12368 },
12369 );
12370 Ok(())
12371 })
12372 });
12373 let alice_mgr_clone = alice_mgr.clone();
12374 bob_transport
12375 .expect_send_message()
12376 .withf(|_, data| msg_type(data, StartProtocolDiscriminants::SessionEstablished))
12377 .returning(move |_, data| {
12378 let alice_mgr_clone = alice_mgr_clone.clone();
12379 Box::pin(async move {
12380 alice_mgr_clone.dispatch_message(
12381 alice_pseudonym,
12382 ApplicationDataIn {
12383 data: data.data,
12384 packet_info: Default::default(),
12385 },
12386 )?;
12387 Ok(())
12388 })
12389 });
12390
12391 let (alice_sender, alice_handle) = mock_packet_planning(alice_transport);
12392 let (new_session_tx_alice, _alice_rx) = futures::channel::mpsc::channel(1024);
12393 alice_mgr.start(alice_sender.clone(), new_session_tx_alice, None, None)?;
12394
12395 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
12396 let (new_session_tx_bob, new_session_rx_bob) = futures::channel::mpsc::channel(1024);
12397 bob_mgr.start(bob_sender.clone(), new_session_tx_bob, None, None)?;
12398 let _bob_notifications = tokio::spawn(async move {
12399 pin_mut!(new_session_rx_bob);
12400 while let Some(_session) = new_session_rx_bob.next().await {}
12401 });
12402
12403 let alice_session = alice_mgr
12404 .new_session(
12405 bob_peer,
12406 SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
12407 SessionClientConfig {
12408 pseudonym: alice_pseudonym.into(),
12409 capabilities: None.into(),
12410 surb_management: None,
12411 ..Default::default()
12412 },
12413 )
12414 .await
12415 .context("the session must establish")?;
12416
12417 let counter = alice_mgr
12418 .sessions
12419 .get(&alice_pseudonym)
12420 .expect("alice must hold the session slot")
12421 .returned_packets;
12422 assert_eq!(
12423 0,
12424 counter.load(std::sync::atomic::Ordering::Relaxed),
12425 "nothing has come back yet"
12426 );
12427
12428 // Three packets arriving the way the Exit's return traffic does.
12429 for i in 0..3u8 {
12430 alice_mgr.dispatch_message(
12431 alice_pseudonym,
12432 ApplicationDataIn {
12433 data: ApplicationData::new(SESSION_APPLICATION_TAG, &[i])?,
12434 packet_info: Default::default(),
12435 },
12436 )?;
12437 }
12438
12439 // The count is taken as the Session drains its receiver, and the receiver is only polled by a
12440 // reader — which is exactly how it behaves in production, where the client is reading.
12441 let mut alice_session = alice_session;
12442 let mut buffer = [0u8; 16];
12443 for _ in 0..50 {
12444 if counter.load(std::sync::atomic::Ordering::Relaxed) >= 3 {
12445 break;
12446 }
12447 let _ = tokio::time::timeout(
12448 Duration::from_millis(20),
12449 futures::AsyncReadExt::read(&mut alice_session, &mut buffer),
12450 )
12451 .await;
12452 }
12453 assert_eq!(
12454 3,
12455 counter.load(std::sync::atomic::Ordering::Relaxed),
12456 "every Exit → Entry packet must be counted, or the successor gate can never open"
12457 );
12458
12459 drop(alice_session);
12460 alice_sender.close_channel();
12461 bob_sender.close_channel();
12462 let _ = alice_handle.await?;
12463 let _ = bob_handle.await?;
12464 Ok(())
12465 }
12466
12467 /// Re-points the fixture's slot at `Forward` routing, the way a real Entry's slot is built.
12468 ///
12469 /// [`entry_with_pix_session`] establishes through `handle_incoming_session_initiation` because
12470 /// that is the path which installs the PIX state the successor gate needs, and it stores the
12471 /// Exit's `Return` reply routing. The gate is an Entry-side rule, and the counter it reads is
12472 /// only credited on Sessions this node *initiated* — Start-protocol traffic on an incoming
12473 /// Session comes from the Entry over the forward path and must not be paid for. So the routing is
12474 /// what decides whether the keep-alives below are read as service arriving or as service being
12475 /// asked for, and the fixture has to be put on the side the rule is about.
12476 ///
12477 /// Re-inserting rather than mutating: the slot lives in a `moka` cache by value, and its routing
12478 /// is a plain field rather than a cell, because nothing in production ever changes it.
12479 fn make_session_outgoing<S>(mgr: &SessionManager<S>, pseudonym: &HoprPseudonym, destination: Address)
12480 where
12481 S: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Clone + Send + Sync + Unpin + 'static,
12482 S::Error: std::error::Error + Send + Sync + 'static,
12483 {
12484 let mut slot = mgr.sessions.get(pseudonym).expect("session must exist");
12485 slot.routing_opts = DestinationRouting::Forward {
12486 destination: Box::new(destination.into()),
12487 pseudonym: Some(*pseudonym),
12488 forward_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN),
12489 return_options: RoutingOptions::Hops(hopr_api::types::primitive::bounded::BoundedSize::MIN).into(),
12490 };
12491 mgr.sessions.insert(*pseudonym, slot);
12492 }
12493
12494 /// A cycle served only by the Exit's keep-alives must still buy its successor.
12495 ///
12496 /// This is the property that lets the Exit finish a funded cycle whose application has fallen
12497 /// silent: it sends its own Exit → Entry keep-alives, each one consuming a return SURB and
12498 /// therefore unlocking one share, and the cycle completes on those alone. If they earned nothing
12499 /// at this gate the Session would die at exactly the moment it succeeded — the Exit's `RequestSsa`
12500 /// for the successor is emitted once and never retried, so a refusal here leaves it in
12501 /// `AwaitingCommitment` until `max_ssa_delivery_time` and then closes the Session as
12502 /// `CommitmentTimeout`, naming a timer rather than the rule that fired.
12503 ///
12504 /// Driven through [`dispatch_message`](SessionManager::dispatch_message) with real encoded
12505 /// keep-alives rather than by touching the counter, because the credit lives on that path and
12506 /// nowhere else: the message never reaches `session_rx`, so the receive-path inspector that
12507 /// [`returned_packets_are_counted_on_the_entry_receive_path`] pins cannot cover for it.
12508 #[test_log::test(tokio::test)]
12509 async fn keep_alives_alone_can_carry_a_cycle_to_its_successor() -> anyhow::Result<()> {
12510 let params = wide_pix_params();
12511 let (mgr, generator, pseudonym, mut pix_events, bob_sender, bob_handle) =
12512 entry_with_pix_session(params, SsaReconstructorConfig::default().early_recovery_threshold).await?;
12513
12514 mgr.handle_ssa_request(
12515 pseudonym,
12516 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(1), []),
12517 )
12518 .await
12519 .context("the opening batch must be accepted")?;
12520 assert_eq!(1, drain_pix_events(&mut pix_events).await);
12521
12522 // Emission reaches the boundary, so service is the only thing left for the gate to weigh.
12523 for sent in 1..=(params.polys_per_ssa() as u32 * params.emitted_shares_per_poly() as u32) {
12524 generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12525 }
12526
12527 make_session_outgoing(&mgr, &pseudonym, (&ChainKeypair::random()).into());
12528
12529 let counter = mgr
12530 .sessions
12531 .get(&pseudonym)
12532 .expect("session must exist")
12533 .returned_packets;
12534 assert_eq!(
12535 0,
12536 counter.load(std::sync::atomic::Ordering::Relaxed),
12537 "the fixture must start with nothing served"
12538 );
12539
12540 // Exactly what the gate demands, and every packet of it a keep-alive.
12541 let required = required_returned_packets(¶ms, 1);
12542 for level in 0..required {
12543 let keep_alive: ApplicationData = HoprStartProtocol::KeepAlive(KeepAliveMessage {
12544 session_id: pseudonym,
12545 flags: KeepAliveFlag::BalancerState.into(),
12546 additional_data: level,
12547 })
12548 .try_into()?;
12549 mgr.dispatch_message(
12550 pseudonym,
12551 ApplicationDataIn {
12552 data: keep_alive,
12553 packet_info: Default::default(),
12554 },
12555 )?;
12556 }
12557 assert_eq!(
12558 required,
12559 counter.load(std::sync::atomic::Ordering::Relaxed),
12560 "every keep-alive consumed a return SURB and must be credited as service"
12561 );
12562
12563 mgr.handle_ssa_request(
12564 pseudonym,
12565 SsaServerCommitmentMessage::new(pseudonym, params, ssa_request_for(2), []),
12566 )
12567 .await
12568 .context("a cycle served entirely by keep-alives must still buy its successor")?;
12569 assert_eq!(1, drain_pix_events(&mut pix_events).await);
12570
12571 bob_sender.close_channel();
12572 bob_handle.await??;
12573 Ok(())
12574 }
12575
12576 /// The Entry's half of the successor gate: a batch asked for before emission has reached the last
12577 /// cycle of the batch already committed must be refused, committing nothing and depositing
12578 /// nothing — and the Session must survive it.
12579 ///
12580 /// The Exit-side gate in `SessionPixSupervisor` means a correct peer never lands here. This is
12581 /// what makes a peer that *does* — an unpatched or hostile Exit answering every cycle's
12582 /// early-recovery signal with a fresh batch — cost the Entry nothing rather than one on-chain
12583 /// deposit per surplus request.
12584 ///
12585 /// Ordering across a batch is pinned where it lives, on the generator, by
12586 /// `emission_never_crosses_a_cycle_boundary_early` and
12587 /// `emission_progress_lags_the_commitment_index_across_a_batch`. What this test drives is the
12588 /// wiring and all three points of the gate: nothing emitted at all, emission having merely
12589 /// *reached* the last committed cycle, and emission far enough into it.
12590 ///
12591 /// The middle point is the regression. Admission used to be "emission has reached the last
12592 /// committed index", which becomes true on that cycle's very first share — so a successor batch
12593 /// was admitted ~0 % into the batch rather than the ~85 % at which a conforming Exit asks. See
12594 /// [`MIN_SUCCESSOR_EMISSION_FRACTION`].
12595 #[test_log::test(tokio::test)]
12596 async fn entry_refuses_a_batch_asked_for_before_emission_reaches_the_current_one() -> anyhow::Result<()> {
12597 use std::collections::BTreeMap;
12598
12599 use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
12600 use hopr_protocol_start::StartInitiation;
12601
12602 const BATCH: u32 = 3;
12603
12604 let generator = Arc::new(SsaShareGenerator::new(SsaGeneratorConfig {
12605 polynomials_per_ssa: 2,
12606 threshold: 2,
12607 surplus_shares: 1,
12608 }));
12609 let (pix_toolbox, pix_events) = PixToolbox::new(
12610 generator.clone(),
12611 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
12612 );
12613 // This manager plays both halves, so its own Exit side opens a cycle and blocks on the
12614 // deposit pool. Answered on the way through; the deposits this test counts pass on unchanged.
12615 let mut pix_events = crate::testing::answering_deposit_pool(pix_events, |_| Vec::new());
12616
12617 let mgr = SessionManager::new(SessionManagerConfig {
12618 max_ssas_per_ssa_request: BATCH as usize,
12619 pix_config: IncomingSessionPixConfig {
12620 quota_range: 0..=1024 * 1024 * 1024,
12621 ..Default::default()
12622 },
12623 ..Default::default()
12624 });
12625
12626 let mut bob_transport = MockMsgSender::new();
12627 bob_transport
12628 .expect_send_message()
12629 .returning(|_, _| Box::pin(async { Ok(()) }));
12630
12631 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
12632 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
12633 let _notifications = tokio::spawn(async move {
12634 pin_mut!(new_session_rx);
12635 while let Some(_session) = new_session_rx.next().await {}
12636 });
12637 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
12638
12639 let pseudonym = HoprPseudonym::random();
12640 mgr.handle_incoming_session_initiation(
12641 pseudonym,
12642 StartInitiation {
12643 challenge: MIN_CHALLENGE,
12644 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
12645 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
12646 additional_data: small_pix_additional_data(),
12647 },
12648 )
12649 .await?;
12650
12651 let identity = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
12652 .expect("identity element must be valid");
12653 let batch = |from: u32| {
12654 (from..from + BATCH)
12655 .map(|i| (SsaIndex::new(i).expect("non-zero"), identity))
12656 .collect::<BTreeMap<_, _>>()
12657 };
12658
12659 // The opening batch is accepted: nothing is committed yet, so there is nothing to be early for.
12660 mgr.handle_ssa_request(
12661 pseudonym,
12662 SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(1), []),
12663 )
12664 .await
12665 .context("the opening batch must be accepted")?;
12666 assert_eq!(
12667 drain_pix_events(&mut pix_events).await,
12668 BATCH as usize,
12669 "the opening batch must yield one deposit address per SSA"
12670 );
12671
12672 // Fully served for the batch, so emission ordering is the only thing under test here. The
12673 // returned-data half of the gate has its own tests.
12674 credit_returned_packets(&mgr, &pseudonym, required_returned_packets(&small_pix_params(), BATCH));
12675
12676 // Asking again before a single share has gone out is a whole batch early.
12677 let err = mgr
12678 .handle_ssa_request(
12679 pseudonym,
12680 SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(BATCH + 1), []),
12681 )
12682 .await
12683 .expect_err("a batch asked for before any share was emitted must be refused");
12684 assert!(
12685 matches!(
12686 err,
12687 TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12688 ),
12689 "expected Unacceptable, got {err:?}"
12690 );
12691 assert_eq!(
12692 drain_pix_events(&mut pix_events).await,
12693 0,
12694 "a refused batch must not emit a single ReadyToDeposit"
12695 );
12696 assert_eq!(
12697 mgr.active_sessions(),
12698 vec![pseudonym],
12699 "refusing a request must not close the Session"
12700 );
12701
12702 // Emit until the last cycle of the batch has taken the front — but no further.
12703 let mut sent = 0u32;
12704 let mut emit_until = |pred: &dyn Fn(&hopr_protocol_pix::EmissionProgress) -> bool| -> anyhow::Result<()> {
12705 for _ in 0..1024 {
12706 if generator.emission_progress(&pseudonym).as_ref().is_some_and(pred) {
12707 return Ok(());
12708 }
12709 sent += 1;
12710 generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12711 }
12712 anyhow::bail!("emission did not reach the expected point")
12713 };
12714 emit_until(&|p| p.is_serving_last_committed())?;
12715
12716 // The boundary the gate actually uses, recomputed from the same negotiated params.
12717 let min_emitted = hopr_protocol_pix::min_emission_for_early_recovery(
12718 &small_pix_params(),
12719 SsaReconstructorConfig::default().early_recovery_threshold,
12720 );
12721
12722 // Reaching the last cycle is *not* enough, and this is the H2 regression. The index-only gate
12723 // this replaced admitted a successor batch right here — on the last cycle's first share, ~0 %
12724 // of the way through the batch — which is nearly a whole cycle of deposits the Exit has not
12725 // earned, on the one gate whose purpose is to prevent exactly that.
12726 let progress = generator.emission_progress(&pseudonym).expect("committed");
12727 assert!(
12728 progress.front_emitted < min_emitted,
12729 "the test must stand at the start of the last cycle, got {} of {min_emitted}",
12730 progress.front_emitted
12731 );
12732 let err = mgr
12733 .handle_ssa_request(
12734 pseudonym,
12735 SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(BATCH + 1), []),
12736 )
12737 .await
12738 .expect_err("reaching the last cycle must not by itself admit the next batch");
12739 assert!(
12740 matches!(
12741 err,
12742 TransportSessionError::Manager(SessionManagerError::Unacceptable(_))
12743 ),
12744 "expected Unacceptable, got {err:?}"
12745 );
12746 assert_eq!(
12747 drain_pix_events(&mut pix_events).await,
12748 0,
12749 "a batch refused for being early must not emit a single ReadyToDeposit"
12750 );
12751
12752 // Far enough into that last cycle, the same batch is admitted.
12753 emit_until(&|p| p.front_emitted >= min_emitted)?;
12754 mgr.handle_ssa_request(
12755 pseudonym,
12756 SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(BATCH + 1), []),
12757 )
12758 .await
12759 .context("a batch asked for on time must be accepted")?;
12760
12761 bob_sender.close_channel();
12762 bob_handle.await??;
12763 Ok(())
12764 }
12765
12766 /// Concurrent `SsaRequest`s for one pseudonym must admit exactly one batch.
12767 ///
12768 /// Start messages are processed under `for_each_concurrent`, so several requests for the same
12769 /// Session run at once, and each reads the successor gate before any of them has advanced it.
12770 /// Requests are issued at distinct, ascending index ranges because that is the shape the
12771 /// generator's monotonic index cannot refuse — it rejects equal or lower indices, and an Exit
12772 /// numbering its batches upwards never offers one.
12773 ///
12774 /// **What this pins is the invariant, not the mechanism.** At this fixture's dimensions — two
12775 /// polynomials at threshold two — `new_ssa_commitment` returns in microseconds, so the window
12776 /// between reading the gate and advancing it is too narrow to hit: removing
12777 /// [`ssa_request_locks`](SessionManager::ssa_request_locks) leaves the outcome unchanged here.
12778 /// The window is a function of that call's cost, which at the deployed 8192 x 64 is around a
12779 /// second, and a fixture that large is not something to build into a unit test. So this test
12780 /// guards the property and the serialisation is justified by the shape of the code rather than
12781 /// by this failing without it.
12782 ///
12783 /// It does earn its place: it is what would catch the gate being made per-entry, the guard being
12784 /// taken after the gate instead of before it, or the handler gaining an await between the two.
12785 #[test_log::test(tokio::test(flavor = "multi_thread", worker_threads = 4))]
12786 async fn concurrent_ssa_requests_for_one_pseudonym_admit_one_batch() -> anyhow::Result<()> {
12787 use std::collections::BTreeMap;
12788
12789 use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
12790 use hopr_protocol_start::StartInitiation;
12791
12792 const BATCH: u32 = 2;
12793 const RACERS: u32 = 4;
12794
12795 let generator = Arc::new(SsaShareGenerator::new(SsaGeneratorConfig {
12796 polynomials_per_ssa: 2,
12797 threshold: 2,
12798 surplus_shares: 1,
12799 }));
12800 let (pix_toolbox, pix_events) = PixToolbox::new(
12801 generator.clone(),
12802 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
12803 );
12804 // This manager plays both halves, so its own Exit side opens a cycle and blocks on the
12805 // deposit pool. Answered on the way through; the deposits this test counts pass on unchanged.
12806 let mut pix_events = crate::testing::answering_deposit_pool(pix_events, |_| Vec::new());
12807
12808 let mgr = SessionManager::new(SessionManagerConfig {
12809 max_ssas_per_ssa_request: BATCH as usize,
12810 pix_config: IncomingSessionPixConfig {
12811 quota_range: 0..=1024 * 1024 * 1024,
12812 ..Default::default()
12813 },
12814 ..Default::default()
12815 });
12816
12817 let mut bob_transport = MockMsgSender::new();
12818 bob_transport
12819 .expect_send_message()
12820 .returning(|_, _| Box::pin(async { Ok(()) }));
12821
12822 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
12823 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
12824 let _notifications = tokio::spawn(async move {
12825 pin_mut!(new_session_rx);
12826 while let Some(_session) = new_session_rx.next().await {}
12827 });
12828 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
12829
12830 let pseudonym = HoprPseudonym::random();
12831 mgr.handle_incoming_session_initiation(
12832 pseudonym,
12833 StartInitiation {
12834 challenge: MIN_CHALLENGE,
12835 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
12836 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
12837 additional_data: small_pix_additional_data(),
12838 },
12839 )
12840 .await?;
12841
12842 let identity = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
12843 .expect("identity element must be valid");
12844 let batch = |from: u32| {
12845 (from..from + BATCH)
12846 .map(|i| (SsaIndex::new(i).expect("non-zero"), identity))
12847 .collect::<BTreeMap<_, _>>()
12848 };
12849
12850 // The opening batch, then emission far enough into its last cycle that exactly one successor
12851 // batch is legitimately admissible.
12852 mgr.handle_ssa_request(
12853 pseudonym,
12854 SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), batch(1), []),
12855 )
12856 .await
12857 .context("the opening batch must be accepted")?;
12858 assert_eq!(BATCH as usize, drain_pix_events(&mut pix_events).await);
12859
12860 // Served for the batch, so that serialisation is the only thing deciding the outcome below.
12861 credit_returned_packets(&mgr, &pseudonym, required_returned_packets(&small_pix_params(), BATCH));
12862
12863 let min_emitted = hopr_protocol_pix::min_emission_for_early_recovery(
12864 &small_pix_params(),
12865 SsaReconstructorConfig::default().early_recovery_threshold,
12866 );
12867 let mut sent = 0u32;
12868 for _ in 0..1024 {
12869 if generator
12870 .emission_progress(&pseudonym)
12871 .is_some_and(|p| p.is_serving_last_committed() && p.front_emitted >= min_emitted)
12872 {
12873 break;
12874 }
12875 sent += 1;
12876 generator.next_share(&pseudonym, &sent.to_be_bytes())?;
12877 }
12878
12879 // Real tasks on a multi-threaded runtime, not `join_all`: `join_all` polls in order, so the
12880 // first future runs its gate check and its whole commitment phase before the second is polled
12881 // at all, and the requests never actually overlap. That shape passes with or without the
12882 // serialisation and so proves nothing about it.
12883 let barrier = Arc::new(tokio::sync::Barrier::new(RACERS as usize));
12884 let outcomes = futures::future::join_all((0..RACERS).map(|r| {
12885 let mgr = mgr.clone();
12886 let barrier = barrier.clone();
12887 let commitments = batch(BATCH + 1 + r * BATCH);
12888 tokio::spawn(async move {
12889 barrier.wait().await;
12890 mgr.handle_ssa_request(
12891 pseudonym,
12892 SsaServerCommitmentMessage::new(pseudonym, small_pix_params(), commitments, []),
12893 )
12894 .await
12895 })
12896 }))
12897 .await
12898 .into_iter()
12899 .map(|joined| joined.expect("racer task must not panic"))
12900 .collect::<Vec<_>>();
12901
12902 let admitted = outcomes.iter().filter(|o| o.is_ok()).count();
12903 assert_eq!(
12904 1, admitted,
12905 "exactly one of {RACERS} concurrent requests may be admitted, got {admitted}: {outcomes:?}"
12906 );
12907 assert_eq!(
12908 BATCH as usize,
12909 drain_pix_events(&mut pix_events).await,
12910 "only the admitted batch may produce deposit instructions"
12911 );
12912
12913 bob_sender.close_channel();
12914 bob_handle.await??;
12915 Ok(())
12916 }
12917
12918 /// An `SsaRequest` asking for more than [`SessionManagerConfig::max_ssas_per_ssa_request`] SSA
12919 /// commitments must be rejected outright, before any commitment is generated or any
12920 /// `ReadyToDeposit` is emitted — and a batch *at* the configured cap must be accepted, so that
12921 /// raising the knob is what actually admits a larger batch.
12922 ///
12923 /// Each accepted entry costs a full client commitment plus its own on-chain deposit, so without
12924 /// this cap one inbound packet could amplify into up to `MAX_SSAS_PER_REQUEST` (27) deposits.
12925 #[test_log::test(tokio::test)]
12926 async fn entry_rejects_ssa_request_exceeding_configured_ssa_cap() -> anyhow::Result<()> {
12927 use std::collections::BTreeMap;
12928
12929 use hopr_crypto_packet::prelude::HoprPixGroupElement;
12930 use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
12931 use hopr_protocol_start::StartInitiation;
12932
12933 /// Outcome of offering a batch: what `handle_ssa_request` returned, how many `SessionError`
12934 /// messages the Entry sent back, and whether it kept the Session.
12935 struct Outcome {
12936 result: errors::Result<()>,
12937 session_errors: usize,
12938 session_alive: bool,
12939 }
12940
12941 // `batch` entries offered against an Entry configured to accept at most `cap`.
12942 async fn offer_batch(cap: usize, batch: u32) -> anyhow::Result<Outcome> {
12943 // The PIX event stream must stay alive: an accepted batch emits one `ReadyToDeposit` per
12944 // entry, and a dropped receiver would fail the send and mask the acceptance as an error.
12945 let (pix_toolbox, _pix_events) = PixToolbox::new(
12946 SsaShareGenerator::new(SsaGeneratorConfig {
12947 polynomials_per_ssa: 2,
12948 threshold: 2,
12949 surplus_shares: 1,
12950 })
12951 .into(),
12952 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
12953 );
12954
12955 let mgr = SessionManager::new(SessionManagerConfig {
12956 pix_config: IncomingSessionPixConfig {
12957 quota_range: 0..=1024 * 1024 * 1024,
12958 ..Default::default()
12959 },
12960 max_ssas_per_ssa_request: cap,
12961 ..Default::default()
12962 });
12963
12964 // Count the SessionError replies: a refusal has to be *told* to the Exit, which cannot
12965 // otherwise observe it and has no path back to a new request.
12966 let session_errors = Arc::new(std::sync::atomic::AtomicUsize::new(0));
12967 let session_errors_tx = session_errors.clone();
12968 let mut bob_transport = MockMsgSender::new();
12969 bob_transport
12970 .expect_send_message()
12971 .times(1..)
12972 .returning(move |_, data| {
12973 if crate::testing::msg_type(&data, StartProtocolDiscriminants::SessionError) {
12974 session_errors_tx.fetch_add(1, Ordering::Relaxed);
12975 }
12976 Box::pin(async { Ok(()) })
12977 });
12978
12979 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
12980 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
12981 let _notifications = tokio::spawn(async move {
12982 pin_mut!(new_session_rx);
12983 while let Some(_session) = new_session_rx.next().await {}
12984 });
12985 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
12986
12987 let alice_pseudonym = HoprPseudonym::random();
12988
12989 mgr.handle_incoming_session_initiation(
12990 alice_pseudonym,
12991 StartInitiation {
12992 challenge: MIN_CHALLENGE,
12993 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
12994 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
12995 additional_data: small_pix_additional_data(),
12996 },
12997 )
12998 .await?;
12999
13000 let session_id = alice_pseudonym;
13001 let identity = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
13002 .expect("identity element must be valid");
13003
13004 // Indices start above whatever the Exit-side establishment already allocated, so the
13005 // accepted case is not rejected for non-monotonicity instead of for its size.
13006 let commitments: BTreeMap<_, _> = (100..100 + batch)
13007 .map(|i| (SsaIndex::new(i).expect("non-zero"), identity))
13008 .collect();
13009
13010 let result = mgr
13011 .handle_ssa_request(
13012 alice_pseudonym,
13013 SsaServerCommitmentMessage::new(
13014 session_id,
13015 PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE)?,
13016 commitments,
13017 [],
13018 ),
13019 )
13020 .await;
13021
13022 let session_alive = mgr.active_sessions().contains(&session_id);
13023
13024 bob_sender.close_channel();
13025 bob_handle.await??;
13026
13027 Ok(Outcome {
13028 result,
13029 session_errors: session_errors.load(Ordering::Relaxed),
13030 session_alive,
13031 })
13032 }
13033
13034 // One over the default cap is rejected...
13035 let over_cap = offer_batch(
13036 DEFAULT_MAX_SSAS_PER_SSA_REQUEST,
13037 DEFAULT_MAX_SSAS_PER_SSA_REQUEST as u32 + 1,
13038 )
13039 .await?;
13040 assert!(
13041 matches!(
13042 over_cap.result,
13043 Err(TransportSessionError::Manager(SessionManagerError::Unacceptable(_)))
13044 ),
13045 "an over-cap SsaRequest must be rejected, got {:?}",
13046 over_cap.result
13047 );
13048 // ...and the refusal must be reported rather than left for the Exit to infer from its own
13049 // deposit timeout minutes later, and must not leave a Session behind that can never make PIX
13050 // progress.
13051 assert_eq!(
13052 1, over_cap.session_errors,
13053 "a refused SsaRequest must send exactly one SessionError back to the Exit"
13054 );
13055 assert!(
13056 !over_cap.session_alive,
13057 "a refused SsaRequest must tear down the Entry's half of the Session"
13058 );
13059
13060 // ...and the very same batch is accepted once the cap is raised to admit it, proving the
13061 // rejection is the configured cap talking and not some other validation.
13062 let raised = DEFAULT_MAX_SSAS_PER_SSA_REQUEST + 1;
13063 let accepted = offer_batch(raised, raised as u32).await?;
13064 assert!(
13065 accepted.result.is_ok(),
13066 "a batch at the configured cap of {raised} must be accepted, got {:?}",
13067 accepted.result
13068 );
13069 assert_eq!(
13070 0, accepted.session_errors,
13071 "an accepted batch must not send a SessionError"
13072 );
13073 assert!(
13074 accepted.session_alive,
13075 "an accepted batch must leave the Session running"
13076 );
13077
13078 Ok(())
13079 }
13080
13081 /// A retransmission request must be capped on the *cycles* it names, not only on the runs the
13082 /// wire can carry.
13083 ///
13084 /// One 8-byte run names a whole cycle, so a request naming every retained cycle is answered with
13085 /// a full commitment burst for each — thousands of packets out of a single packet in. The
13086 /// generator's per-cycle attempt cap does not bound that: it bounds repeats of one cycle, and
13087 /// each cycle here is being asked for the first time.
13088 #[test_log::test(tokio::test)]
13089 async fn entry_rejects_a_retransmission_request_naming_too_many_cycles() -> anyhow::Result<()> {
13090 use std::collections::BTreeMap;
13091
13092 use hopr_crypto_packet::prelude::HoprPixGroupElement;
13093 use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
13094 use hopr_protocol_start::StartInitiation;
13095
13096 /// Commits `cap` cycles to an Entry capped at `cap`, then asks it to retransmit `cycles` of
13097 /// them, and reports what `handle_ssa_request` made of the second request together with every
13098 /// `SsaCommit` the Entry sent across both — one per cycle at these dimensions, so the
13099 /// commitment alone accounts for `cap` of them.
13100 ///
13101 /// Committing first is what gives the assertions their teeth: an uncapped handler answers
13102 /// every *retained* index it is named before it trips over the first one it does not hold, so
13103 /// the packet count — not the error — is what separates a gate from an accident. The count is
13104 /// read only after the send channel has drained, since the bursts are queued rather than sent
13105 /// inline and a running total would race them.
13106 async fn ask_for_cycles(cap: usize, cycles: u32) -> anyhow::Result<(errors::Result<()>, usize)> {
13107 let (pix_toolbox, _pix_events) = PixToolbox::new(
13108 SsaShareGenerator::new(SsaGeneratorConfig {
13109 polynomials_per_ssa: 2,
13110 threshold: 2,
13111 surplus_shares: 1,
13112 })
13113 .into(),
13114 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13115 );
13116
13117 let mgr = SessionManager::new(SessionManagerConfig {
13118 pix_config: IncomingSessionPixConfig {
13119 quota_range: 0..=1024 * 1024 * 1024,
13120 ..Default::default()
13121 },
13122 max_ssas_per_ssa_request: cap,
13123 ..Default::default()
13124 });
13125
13126 // The amplification is the thing being bounded, so count what actually goes out.
13127 let commits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13128 let commits_tx = commits.clone();
13129 let mut bob_transport = MockMsgSender::new();
13130 bob_transport
13131 .expect_send_message()
13132 .times(1..)
13133 .returning(move |_, data| {
13134 if crate::testing::msg_type(&data, StartProtocolDiscriminants::SsaCommit) {
13135 commits_tx.fetch_add(1, Ordering::Relaxed);
13136 }
13137 Box::pin(async { Ok(()) })
13138 });
13139
13140 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13141 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13142 let _notifications = tokio::spawn(async move {
13143 pin_mut!(new_session_rx);
13144 while let Some(_session) = new_session_rx.next().await {}
13145 });
13146 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
13147
13148 let alice_pseudonym = HoprPseudonym::random();
13149 mgr.handle_incoming_session_initiation(
13150 alice_pseudonym,
13151 StartInitiation {
13152 challenge: MIN_CHALLENGE,
13153 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13154 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13155 additional_data: small_pix_additional_data(),
13156 },
13157 )
13158 .await?;
13159
13160 let params = PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE)?;
13161 let identity = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
13162 .expect("identity element must be valid");
13163
13164 // Commit a full batch first, so there is real material for the retransmission to name.
13165 let commitments: BTreeMap<_, _> = (100..100 + cap as u32)
13166 .map(|i| (SsaIndex::new(i).expect("non-zero"), identity))
13167 .collect();
13168 mgr.handle_ssa_request(
13169 alice_pseudonym,
13170 SsaServerCommitmentMessage::new(alice_pseudonym, params, commitments, []),
13171 )
13172 .await?;
13173
13174 // One run apiece, each covering its whole cycle: the cheapest request to send and the
13175 // most expensive to answer.
13176 let missing = (100..100 + cycles).map(|i| (SsaIndex::new(i).expect("non-zero"), vec![(0u16, 1u16)]));
13177
13178 let result = mgr
13179 .handle_ssa_request(
13180 alice_pseudonym,
13181 SsaServerCommitmentMessage::recommit(alice_pseudonym, params, missing),
13182 )
13183 .await;
13184
13185 bob_sender.close_channel();
13186 bob_handle.await??;
13187
13188 Ok((result, commits.load(Ordering::Relaxed)))
13189 }
13190
13191 // One cycle over the cap is refused whole, and — the part that matters — answered with
13192 // nothing at all, rather than with a burst for each retained cycle it managed to name first.
13193 let (over_cap, over_cap_commits) = ask_for_cycles(
13194 DEFAULT_MAX_SSAS_PER_SSA_REQUEST,
13195 DEFAULT_MAX_SSAS_PER_SSA_REQUEST as u32 + 1,
13196 )
13197 .await?;
13198 assert!(
13199 matches!(
13200 over_cap,
13201 Err(TransportSessionError::Manager(SessionManagerError::Unacceptable(_)))
13202 ),
13203 "a retransmission request over the cycle cap must be refused, got {over_cap:?}"
13204 );
13205 assert_eq!(
13206 DEFAULT_MAX_SSAS_PER_SSA_REQUEST, over_cap_commits,
13207 "a refused request must add nothing to the {DEFAULT_MAX_SSAS_PER_SSA_REQUEST} messages the commitment \
13208 itself cost"
13209 );
13210
13211 // ...and the very same cycles are repaired once they fit the cap, so the refusal above is the
13212 // cap talking and not some other validation the request also trips.
13213 let (at_cap, at_cap_commits) = ask_for_cycles(
13214 DEFAULT_MAX_SSAS_PER_SSA_REQUEST,
13215 DEFAULT_MAX_SSAS_PER_SSA_REQUEST as u32,
13216 )
13217 .await?;
13218 assert!(
13219 at_cap.is_ok(),
13220 "a request at the cycle cap must be answered, got {at_cap:?}"
13221 );
13222 assert_eq!(
13223 2 * DEFAULT_MAX_SSAS_PER_SSA_REQUEST,
13224 at_cap_commits,
13225 "every named cycle must be re-sent, one message each on top of the commitment's own"
13226 );
13227
13228 Ok(())
13229 }
13230
13231 /// A batch with one undecodable member must publish nothing at all.
13232 ///
13233 /// `handle_ssa_request` used to generate, send and emit per entry, deciding each member's fate
13234 /// before looking at the next. A batch whose *second* exit commitment failed to decode had
13235 /// therefore already sent the first member's `SsaCommit` burst and emitted its `ReadyToDeposit`
13236 /// — an instruction to put money on chain — for a request that is then rejected as a whole. The
13237 /// Exit has no cycle to spend that deposit against, and the deposit key needs its half.
13238 ///
13239 /// The first member is valid and the second is not, so a per-entry implementation passes the
13240 /// "batch is refused" half of this test while still leaking the first deposit.
13241 #[test_log::test(tokio::test)]
13242 async fn a_batch_with_one_undecodable_commitment_publishes_nothing() -> anyhow::Result<()> {
13243 use std::collections::BTreeMap;
13244
13245 use futures::FutureExt;
13246 use hopr_crypto_packet::prelude::HoprPixGroupElement;
13247 use hopr_protocol_pix::{PixGroup, SsaGeneratorConfig, SsaReconstructorConfig};
13248 use hopr_protocol_start::StartInitiation;
13249
13250 let (pix_toolbox, pix_events) = PixToolbox::new(
13251 SsaShareGenerator::new(SsaGeneratorConfig {
13252 polynomials_per_ssa: 2,
13253 threshold: 2,
13254 surplus_shares: 1,
13255 })
13256 .into(),
13257 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13258 );
13259
13260 let mgr = SessionManager::new(SessionManagerConfig {
13261 pix_config: IncomingSessionPixConfig {
13262 quota_range: 0..=1024 * 1024 * 1024,
13263 ..Default::default()
13264 },
13265 ..Default::default()
13266 });
13267
13268 // `SsaCommit` is the observable half of "a commitment was published"; `SessionError` is the
13269 // refusal. Both are counted, because the bug shows up as the two happening together.
13270 let commits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13271 let session_errors = Arc::new(std::sync::atomic::AtomicUsize::new(0));
13272 let (commits_tx, errors_tx) = (commits.clone(), session_errors.clone());
13273 let mut bob_transport = MockMsgSender::new();
13274 bob_transport.expect_send_message().returning(move |_, data| {
13275 if crate::testing::msg_type(&data, StartProtocolDiscriminants::SsaCommit) {
13276 commits_tx.fetch_add(1, Ordering::Relaxed);
13277 }
13278 if crate::testing::msg_type(&data, StartProtocolDiscriminants::SessionError) {
13279 errors_tx.fetch_add(1, Ordering::Relaxed);
13280 }
13281 Box::pin(async { Ok(()) })
13282 });
13283
13284 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13285 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13286 let _notifications = tokio::spawn(async move {
13287 pin_mut!(new_session_rx);
13288 while let Some(_session) = new_session_rx.next().await {}
13289 });
13290 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
13291
13292 let alice_pseudonym = HoprPseudonym::random();
13293 mgr.handle_incoming_session_initiation(
13294 alice_pseudonym,
13295 StartInitiation {
13296 challenge: MIN_CHALLENGE,
13297 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13298 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13299 additional_data: small_pix_additional_data(),
13300 },
13301 )
13302 .await?;
13303
13304 let valid = HoprPixGroupElement::try_from(PixGroup::<HoprPixSpec>::default().to_bytes().as_ref())
13305 .expect("identity element must be valid");
13306 // All-ones is not a compressed point on the curve, so it fails before the subgroup check.
13307 // The length is taken from the type rather than written out: the group representation is
13308 // curve-dependent, and a literal silently becomes a *length* rejection under a different
13309 // `HoprPixSpec` — which is a different code path from the one under test.
13310 // Spelled out because the group repr is a `hybrid_array::Array` with several `AsRef` impls.
13311 let repr_len = AsRef::<[u8]>::as_ref(&PixGroup::<HoprPixSpec>::default().to_bytes()).len();
13312 let garbage =
13313 HoprPixGroupElement::try_from(vec![0xffu8; repr_len].as_slice()).expect("length must be accepted");
13314 assert!(
13315 garbage.try_into_pix_group().is_err(),
13316 "the test fixture must actually be undecodable"
13317 );
13318
13319 let commitments: BTreeMap<_, _> = BTreeMap::from([
13320 (SsaIndex::new(100).expect("non-zero"), valid),
13321 (SsaIndex::new(101).expect("non-zero"), garbage),
13322 ]);
13323
13324 let result = mgr
13325 .handle_ssa_request(
13326 alice_pseudonym,
13327 SsaServerCommitmentMessage::new(
13328 alice_pseudonym,
13329 PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE)?,
13330 commitments,
13331 [],
13332 ),
13333 )
13334 .await;
13335
13336 let session_alive = mgr.active_sessions().contains(&alice_pseudonym);
13337
13338 // Drain the mock transport before counting: `mock_packet_planning` delivers on a spawned
13339 // task, so a count taken here would race the sends rather than observe them.
13340 bob_sender.close_channel();
13341 bob_handle.await??;
13342
13343 assert!(
13344 matches!(
13345 result,
13346 Err(TransportSessionError::Manager(SessionManagerError::Unacceptable(_)))
13347 ),
13348 "an undecodable member must be refused, got {result:?}"
13349 );
13350 assert_eq!(
13351 0,
13352 commits.load(Ordering::Relaxed),
13353 "no SsaCommit may be sent for a batch that is refused as a whole"
13354 );
13355 assert_eq!(
13356 1,
13357 session_errors.load(Ordering::Relaxed),
13358 "the refusal must be reported to the Exit"
13359 );
13360 assert!(
13361 !session_alive,
13362 "an undecodable commitment is terminal, like every other unacceptable-parameter case"
13363 );
13364
13365 // The point of the test: not one deposit instruction escaped for the valid first member.
13366 pin_mut!(pix_events);
13367 assert!(
13368 pix_events.next().now_or_never().flatten().is_none(),
13369 "no ReadyToDeposit may be emitted for a batch that is refused as a whole"
13370 );
13371
13372 Ok(())
13373 }
13374
13375 /// The Start protocol ingress channel must be sized for the worst-case commitment burst of a
13376 /// single SSA cycle, not for the number of sessions.
13377 ///
13378 /// A dropped `SsaCommit` is unrecoverable — there is no retransmission — so the queue has to
13379 /// absorb the whole commitment set a cycle can deliver, capped by the polynomial ceiling
13380 /// `check_pix_params` enforces.
13381 #[test]
13382 fn start_protocol_channel_is_sized_for_the_worst_case_commitment_burst() {
13383 let cfg = SessionManagerConfig::default();
13384 let capacity = start_protocol_channel_capacity(&cfg);
13385
13386 // Number of commitments implied by the largest quota this node accepts, clamped by the
13387 // number of polynomials it would actually admit.
13388 let commitments = (*cfg.pix_config.quota_range.end() / PIX_QUOTA_BYTES_PER_SHARE).min(MAX_POLYS_PER_SSA as u64);
13389 let min_expected = commitments.div_ceil(MIN_COMMITMENTS_PER_SSA_COMMIT_MSG as u64) as usize;
13390
13391 assert!(
13392 capacity >= min_expected + cfg.maximum_sessions,
13393 "capacity {capacity} must cover the {min_expected}-message commitment burst plus room for {} concurrent \
13394 session setups",
13395 cfg.maximum_sessions
13396 );
13397
13398 // The commitment burst must be the term that was added, not incidental slack: the old
13399 // sizing was `maximum_sessions + 10` and carried no PIX component at all.
13400 assert_eq!(
13401 min_expected,
13402 capacity - cfg.maximum_sessions - START_PROTOCOL_CHANNEL_RESERVE,
13403 "the PIX commitment burst must be an explicit component of the capacity"
13404 );
13405 assert!(
13406 min_expected > 0,
13407 "a non-zero accepted quota must imply commitment messages"
13408 );
13409
13410 // Guards against a units mistake (e.g. counting bytes rather than messages) turning this
13411 // into a multi-gigabyte ring allocation.
13412 assert!(capacity < 1_000_000, "capacity {capacity} is implausibly large");
13413 }
13414
13415 /// `quota_range` is operator-settable and the capacity it feeds is *reserved*, not merely
13416 /// enforced — `crossfire`'s array flavour pre-allocates every slot. So the derivation must be
13417 /// bounded independently of the configured quota.
13418 ///
13419 /// Regression test: an unclamped derivation asked for ~4.2e8 slots here (77 GB), which aborted
13420 /// the whole unit test binary with an allocation failure rather than failing one assertion. The
13421 /// sizing test above never caught it because it only exercises the default config.
13422 #[test]
13423 fn start_protocol_channel_capacity_is_bounded_for_any_quota_range() {
13424 // The commitment term saturates once the quota admits more commitments than there are
13425 // polynomials, so this is the largest value it can ever take.
13426 let saturated = (MAX_POLYS_PER_SSA as u64).div_ceil(MIN_COMMITMENTS_PER_SSA_COMMIT_MSG as u64) as usize;
13427
13428 for quota_end in [10_u64.pow(13), u64::MAX] {
13429 let cfg = SessionManagerConfig {
13430 pix_config: IncomingSessionPixConfig {
13431 quota_range: 0..=quota_end,
13432 ..Default::default()
13433 },
13434 ..Default::default()
13435 };
13436 let capacity = start_protocol_channel_capacity(&cfg);
13437
13438 assert_eq!(
13439 capacity,
13440 saturated + cfg.maximum_sessions + START_PROTOCOL_CHANNEL_RESERVE,
13441 "quota_range end {quota_end} must not grow the commitment term past the polynomial ceiling"
13442 );
13443 }
13444
13445 // The session term is reserved for exactly the same reason, and `maximum_managed_sessions`
13446 // validates up to 100 000 — so it needs its own ceiling, not just the commitment one.
13447 let cfg = SessionManagerConfig {
13448 maximum_sessions: 100_000,
13449 ..Default::default()
13450 };
13451 assert_eq!(
13452 start_protocol_channel_capacity(&cfg),
13453 saturated + MAX_CONCURRENT_START_EXCHANGES + START_PROTOCOL_CHANNEL_RESERVE,
13454 "a large session limit must not grow the pre-allocated ring past the handshake ceiling"
13455 );
13456
13457 // The batch factor is reserved too: a batch of N draws N cycles' commitment sets into this
13458 // one channel, and a dropped `SsaCommit` is unrecoverable. It must scale the commitment term
13459 // and nothing else, and must itself be bounded by `MAX_SSA_BATCH_SIZE` even when the config
13460 // was never clamped by `SessionManager::new`.
13461 let batched = |ssas_per_request| {
13462 start_protocol_channel_capacity(&SessionManagerConfig {
13463 pix_config: IncomingSessionPixConfig {
13464 quota_range: 0..=u64::MAX,
13465 supervision: SupervisorConfig {
13466 ssas_per_request,
13467 ..Default::default()
13468 },
13469 ..Default::default()
13470 },
13471 ..Default::default()
13472 })
13473 };
13474 let unbatched = SessionManagerConfig::default();
13475 for ssas_per_request in [1, 2, MAX_SSA_BATCH_SIZE] {
13476 assert_eq!(
13477 batched(ssas_per_request),
13478 saturated * ssas_per_request + unbatched.maximum_sessions + START_PROTOCOL_CHANNEL_RESERVE,
13479 "the commitment term must scale with ssas_per_request = {ssas_per_request}"
13480 );
13481 }
13482 assert_eq!(
13483 batched(MAX_SSA_BATCH_SIZE + 1),
13484 batched(MAX_SSA_BATCH_SIZE),
13485 "an unclamped ssas_per_request must not inflate the pre-allocated ring"
13486 );
13487 assert_eq!(
13488 batched(0),
13489 batched(1),
13490 "a zero ssas_per_request must not collapse the commitment term"
13491 );
13492 }
13493
13494 /// Verifies that once the exit/responder (Bob) has set up the SSA state, delivering coefficient
13495 /// commits for all polynomials causes the PIX event stream to emit `DepositNeeded`.
13496 ///
13497 /// ## Steps
13498 /// 1. Bob's manager is started with a `PixToolbox` configured for `polynomials_per_ssa=2, threshold=2,
13499 /// surplus_shares=1`. Alice's session initiation is processed normally.
13500 /// 2. The exit has already registered an exit commitment from `handle_incoming_session_initiation`.
13501 /// 3. Coefficient 0 (constant terms across all polynomials) is delivered via `handle_ssa_commit` using identity
13502 /// group elements as dummy commitments.
13503 /// 4. Coefficient 1 (linear terms) is delivered similarly.
13504 /// 5. After the second coefficient delivery, Bob's PIX event stream emits `DepositNeeded` with the correct `SsaId`
13505 /// and `quota_per_ssa` matching `pix_params_to_quota(2, 2)`.
13506 /// 6. The event is received within a 2-second timeout.
13507 #[test_log::test(tokio::test)]
13508 async fn exit_receives_ssa_commits_and_emits_deposit_needed_event() -> anyhow::Result<()> {
13509 use std::collections::HashMap;
13510
13511 use hopr_crypto_packet::prelude::{HoprPixCommitmentProof, HoprPixGroupElement};
13512 use hopr_protocol_pix::{
13513 Field, PixGroup, PixScalar, PolynomialIndex, SsaCommitmentProof, SsaGeneratorConfig, SsaReconstructor,
13514 SsaReconstructorConfig, SsaShareGenerator,
13515 };
13516 use hopr_protocol_start::StartInitiation;
13517
13518 let ssa_gen_config = SsaGeneratorConfig {
13519 polynomials_per_ssa: 2,
13520 threshold: 2,
13521 surplus_shares: 1,
13522 };
13523
13524 let (pix_toolbox, pix_events_rx) = PixToolbox::new(
13525 SsaShareGenerator::new(ssa_gen_config).into(),
13526 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13527 );
13528 // This test reads the event stream itself, so the pool has to be played on the way through:
13529 // the Exit blocks its SSA request on the answer, and the request would otherwise be the
13530 // first event read here.
13531 let pix_events_rx =
13532 crate::testing::answering_deposit_pool(pix_events_rx, |id| vec![id.ssa_index().get() as u8; 3]);
13533
13534 let mgr = SessionManager::new(SessionManagerConfig {
13535 pix_config: IncomingSessionPixConfig {
13536 quota_range: 0..=1024 * 1024 * 1024,
13537 ..Default::default()
13538 },
13539 ..Default::default()
13540 });
13541
13542 let mut bob_transport = MockMsgSender::new();
13543 bob_transport
13544 .expect_send_message()
13545 .returning(|_, _| Box::pin(async { Ok(()) }));
13546 // handle_incoming_session_initiation sends SessionEstablished + SsaRequest.
13547 bob_transport
13548 .expect_send_message()
13549 .returning(|_, _| Box::pin(async { Ok(()) }));
13550
13551 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13552 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13553 let _notifications = tokio::spawn(async move {
13554 pin_mut!(new_session_rx);
13555 while let Some(_session) = new_session_rx.next().await {}
13556 });
13557 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox.clone()), None)?;
13558
13559 let alice_pseudonym = HoprPseudonym::random();
13560
13561 mgr.handle_incoming_session_initiation(
13562 alice_pseudonym,
13563 StartInitiation {
13564 challenge: MIN_CHALLENGE,
13565 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13566 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13567 additional_data: small_pix_additional_data(),
13568 },
13569 )
13570 .await?;
13571
13572 // The exit commitment is registered by the supervisor's opening `RequestSsa`, which the
13573 // action driver carries out asynchronously — so wait for it rather than assume it.
13574 let ssa_id = SsaId::new(alice_pseudonym, SsaIndex::MIN);
13575 {
13576 let share_processor = pix_toolbox.share_processor.clone();
13577 tokio::time::timeout(Duration::from_secs(1), async {
13578 while !share_processor.contains_builder(&ssa_id) {
13579 tokio::time::sleep(Duration::from_millis(10)).await;
13580 }
13581 })
13582 .await
13583 .context("the opening SSA request never registered an exit commitment")?;
13584 }
13585
13586 // Deliver coefficient 0 (constant terms across all polynomials).
13587 // Use the identity/infinity group element as a dummy commitment.
13588 // PixGroup<HoprPixSpec> = k256::ProjectivePoint, which has identity/infinity as all-zero bytes.
13589 let identity_element = {
13590 let g: PixGroup<HoprPixSpec> = Default::default();
13591 HoprPixGroupElement::try_from(g.to_bytes().as_ref()).expect("identity element must be valid")
13592 };
13593 let mut coeff_0_map = HashMap::new();
13594 for poly in 0..2 {
13595 coeff_0_map.insert(poly as PolynomialIndex, identity_element);
13596 }
13597 // The dummy constant terms sum to the identity, whose discrete logarithm is zero, so an
13598 // honest proof of knowledge over it is constructible — the Exit refuses the cycle without
13599 // one.
13600 let zero = <PixScalar<HoprPixSpec> as Field>::ZERO;
13601 let identity_proof = HoprPixCommitmentProof::from(
13602 SsaCommitmentProof::<HoprPixSpec>::prove(&ssa_id, &zero, &PixGroup::<HoprPixSpec>::default())
13603 .expect("identity proof must be constructible"),
13604 );
13605 mgr.handle_ssa_commit(
13606 alice_pseudonym,
13607 SsaClientCommitmentMessage {
13608 session_id: alice_pseudonym,
13609 ssa_index: SsaIndex::MIN,
13610 coefficient_index: 0,
13611 commitment_proof: Some(identity_proof),
13612 coefficient_commitments: coeff_0_map,
13613 },
13614 )
13615 .await?;
13616
13617 // Deliver coefficient 1 (linear terms across all polynomials).
13618 let mut coeff_1_map = HashMap::new();
13619 for poly in 0..2 {
13620 coeff_1_map.insert(poly as PolynomialIndex, identity_element);
13621 }
13622 mgr.handle_ssa_commit(
13623 alice_pseudonym,
13624 SsaClientCommitmentMessage {
13625 session_id: alice_pseudonym,
13626 ssa_index: SsaIndex::MIN,
13627 coefficient_index: 1,
13628 commitment_proof: None,
13629 coefficient_commitments: coeff_1_map,
13630 },
13631 )
13632 .await?;
13633
13634 // The first coefficient commitment should trigger DepositNeeded.
13635 pin_mut!(pix_events_rx);
13636 let event = tokio::time::timeout(std::time::Duration::from_secs(2), pix_events_rx.next())
13637 .await
13638 .map_err(|e| anyhow::anyhow!("timeout waiting for pix event: {e}"))?
13639 .ok_or_else(|| anyhow::anyhow!("pix_events_rx closed without emitting an event"))?;
13640
13641 assert!(matches!(
13642 event,
13643 HoprSessionOutPixEvent::DepositNeeded(AgreedSsaQuota { ssa_id: ref received_ssa_id, .. }, _)
13644 if received_ssa_id == &ssa_id
13645 ));
13646
13647 let HoprSessionOutPixEvent::DepositNeeded(quota, _) = event else {
13648 unreachable!();
13649 };
13650 assert_eq!(quota.quota_per_ssa, pix_params_to_quota(&small_pix_params()));
13651
13652 bob_sender.close_channel();
13653 bob_handle.await??;
13654
13655 Ok(())
13656 }
13657
13658 /// Verifies that a PIX Session whose Entry never answers the `SsaRequest` is closed when the
13659 /// commitment deadline expires.
13660 ///
13661 /// This used to be spelled as a deposit-timeout test, with `max_ssa_delivery_time: 0` and a
13662 /// 50 ms `max_deposit_wait`. A zero delivery time makes the *commitment* deadline fire
13663 /// immediately, so the assertion held whatever the deposit deadline did — it would have passed
13664 /// with that deadline removed entirely. The deposit deadline cannot be reached from this
13665 /// fixture at all: arming it needs a `CommitmentVerified`, and a mock transport has no Entry to
13666 /// send one. That path is covered end-to-end by `hopr-lib`'s `deposit_timeout_closes_session`.
13667 ///
13668 /// So `max_deposit_wait` is set far out of reach here, leaving the commitment deadline as the
13669 /// only thing that can close the Session.
13670 #[test_log::test(tokio::test)]
13671 async fn session_is_closed_when_the_entry_never_commits() -> anyhow::Result<()> {
13672 use std::time::Duration;
13673
13674 use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
13675 use hopr_protocol_start::StartInitiation;
13676
13677 let ssa_gen_config = SsaGeneratorConfig {
13678 polynomials_per_ssa: 2,
13679 threshold: 2,
13680 surplus_shares: 1,
13681 };
13682
13683 let (pix_toolbox, _) = PixToolbox::new(
13684 SsaShareGenerator::new(ssa_gen_config).into(),
13685 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13686 );
13687
13688 let mgr = SessionManager::new(SessionManagerConfig {
13689 pix_config: IncomingSessionPixConfig {
13690 quota_range: 0..=1024 * 1024 * 1024,
13691 supervision: SupervisorConfig {
13692 // Short, so the deadline under test fires quickly.
13693 max_ssa_delivery_time: Duration::from_millis(50),
13694 // Scaled with it, since it has to stay under the delivery deadline. Nothing arms
13695 // it here — the Entry sends no commitment at all, so there is no scope to
13696 // re-request and the deadline under test is the only one that can fire.
13697 commitment_recommit_interval: Duration::from_millis(10),
13698 // Far out of reach, so it cannot be what closes the Session.
13699 max_deposit_wait: Duration::from_secs(3600),
13700 ..Default::default()
13701 },
13702 ..Default::default()
13703 },
13704 ..Default::default()
13705 });
13706
13707 let mut bob_transport = MockMsgSender::new();
13708 // handle_incoming_session_initiation sends SessionEstablished + SsaRequest (2 messages).
13709 bob_transport
13710 .expect_send_message()
13711 .returning(|_, _| Box::pin(async { Ok(()) }));
13712 bob_transport
13713 .expect_send_message()
13714 .returning(|_, _| Box::pin(async { Ok(()) }));
13715
13716 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13717 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13718 let _notifications = tokio::spawn(async move {
13719 pin_mut!(new_session_rx);
13720 while let Some(_session) = new_session_rx.next().await {}
13721 });
13722 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox.clone()), None)?;
13723
13724 let alice_pseudonym = HoprPseudonym::random();
13725
13726 mgr.handle_incoming_session_initiation(
13727 alice_pseudonym,
13728 StartInitiation {
13729 challenge: MIN_CHALLENGE,
13730 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13731 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13732 additional_data: small_pix_additional_data(),
13733 },
13734 )
13735 .await?;
13736
13737 // Session is active after establishment.
13738 assert_eq!(vec![alice_pseudonym], mgr.active_sessions());
13739
13740 // The supervisor arms the commitment deadline once the `SsaRequest` goes out and closes the
13741 // Session 50 ms later; the teardown that follows is asynchronous.
13742 tokio::time::timeout(Duration::from_secs(2), async {
13743 while mgr.num_active_sessions() > 0 {
13744 tokio::time::sleep(Duration::from_millis(10)).await;
13745 }
13746 })
13747 .await
13748 .context("session should be closed once the commitment deadline expires")?;
13749
13750 assert!(mgr.active_sessions().is_empty());
13751
13752 bob_sender.close_channel();
13753 bob_handle.await??;
13754
13755 Ok(())
13756 }
13757
13758 /// The surplus an Entry offers must survive the round trip: stored by the Exit, then echoed
13759 /// back in the `SsaRequest` it sends.
13760 ///
13761 /// It is the one negotiated value with no other consumer — it is not part of the priced quota,
13762 /// so no quota check would notice it going missing, and the Exit does not act on it yet. Every
13763 /// other dimension is pinned several times over by the checks around it.
13764 #[test_log::test(tokio::test)]
13765 async fn exit_stores_and_echoes_the_offered_surplus() -> anyhow::Result<()> {
13766 use hopr_protocol_pix::SsaReconstructorConfig;
13767 use hopr_protocol_start::StartInitiation;
13768
13769 // Distinct from every default, so a value read from local config instead of the wire is
13770 // visibly wrong rather than accidentally right.
13771 const OFFERED_SURPLUS: u8 = 37;
13772
13773 let pix_toolbox = pix_toolbox_with_pool(
13774 SsaShareGenerator::new(SsaGeneratorConfig {
13775 polynomials_per_ssa: 2,
13776 threshold: 2,
13777 surplus_shares: TEST_SURPLUS_SHARES,
13778 })
13779 .into(),
13780 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
13781 );
13782
13783 let mgr = SessionManager::new(SessionManagerConfig {
13784 pix_config: IncomingSessionPixConfig {
13785 quota_range: 0..=1024 * 1024 * 1024,
13786 ..Default::default()
13787 },
13788 ..Default::default()
13789 });
13790
13791 let echoed: Arc<std::sync::Mutex<Vec<PixParams>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
13792 let echoed_clone = echoed.clone();
13793 let mut bob_transport = MockMsgSender::new();
13794 bob_transport.expect_send_message().returning(move |_, data| {
13795 if let Ok(HoprStartProtocol::SsaRequest(req)) = HoprStartProtocol::try_from(data.data)
13796 && let Ok(params) = req.dimensions()
13797 {
13798 echoed_clone.lock().unwrap().push(params);
13799 }
13800 Box::pin(async { Ok(()) })
13801 });
13802
13803 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
13804 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
13805 let _notifications = tokio::spawn(async move {
13806 pin_mut!(new_session_rx);
13807 while let Some(_session) = new_session_rx.next().await {}
13808 });
13809 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox), None)?;
13810
13811 let alice_pseudonym = HoprPseudonym::random();
13812 mgr.handle_incoming_session_initiation(
13813 alice_pseudonym,
13814 StartInitiation {
13815 challenge: MIN_CHALLENGE,
13816 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
13817 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13818 additional_data: pix_additional_data(2, 2, OFFERED_SURPLUS),
13819 },
13820 )
13821 .await?;
13822
13823 let expected = PixParams::try_new(2, 2, OFFERED_SURPLUS, LOCAL_PIX_SUITE)?;
13824
13825 let slot = mgr.sessions.get(&alice_pseudonym).context("session must exist")?;
13826 let ssa_state = slot.current_ssa_state.get().context("pix state must be set")?;
13827 assert_eq!(
13828 expected, ssa_state.params,
13829 "the Exit must keep the surplus the Entry offered, not its own"
13830 );
13831
13832 // The echo does not go out inline: the supervisor's opening `RequestSsa` is carried out by
13833 // the action driver spawned once the Session is published, which is what keeps
13834 // `SessionEstablished` ahead of `SsaRequest` on the wire.
13835 tokio::time::timeout(Duration::from_secs(1), async {
13836 while echoed.lock().unwrap().is_empty() {
13837 tokio::time::sleep(Duration::from_millis(10)).await;
13838 }
13839 })
13840 .await
13841 .context("the supervisor's opening SsaRequest never reached the wire")?;
13842
13843 bob_sender.close_channel();
13844 bob_handle.await??;
13845
13846 let echoed = echoed.lock().unwrap().clone();
13847 assert_eq!(
13848 vec![expected],
13849 echoed,
13850 "the SsaRequest must echo back exactly what was offered"
13851 );
13852
13853 Ok(())
13854 }
13855
13856 /// Verifies that `check_pix_params` rejects out-of-bounds parameters that pass the
13857 /// quota-range check but exceed the protocol limits.
13858 ///
13859 /// This is a regression test for the incentive-bypass fix (round-1 finding #1).
13860 #[test_log::test(tokio::test)]
13861 async fn check_pix_params_must_reject_invalid_bounds() -> anyhow::Result<()> {
13862 let mgr =
13863 SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(SessionManagerConfig {
13864 pix_config: IncomingSessionPixConfig {
13865 quota_range: 0..=10_000_000_000_000,
13866 ..Default::default()
13867 },
13868 ..Default::default()
13869 });
13870
13871 let offer = |additional_data: u64| StartInitiation {
13872 challenge: 0,
13873 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse().unwrap())),
13874 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13875 additional_data: into_initiation_additional_data(additional_data),
13876 };
13877 // Packed by hand rather than through `pix_additional_data`, because that helper refuses to
13878 // build the very values under test. The layout it mirrors is pinned in `PixParams`' own
13879 // tests. The suite is this build's, so that the dimension cases below fail on their
13880 // dimensions rather than on a curve mismatch — that rejection has its own test.
13881 let packed = |polys: u16, shares: u8, surplus: u8| {
13882 ((LOCAL_PIX_SUITE as u64) << 62)
13883 | ((polys as u64) << 48)
13884 | ((shares as u64) << 40)
13885 | ((surplus as u64) << 32)
13886 };
13887
13888 // polys_per_ssa > MAX_POLYS_PER_SSA (16192), and zero, with a valid quota -> should reject
13889 for polys in [0, MAX_POLYS_PER_SSA + 1, u16::MAX] {
13890 assert!(
13891 decode_and_check(&mgr, &offer(packed(polys, 128, 0))).is_none(),
13892 "should reject polys_per_ssa {polys}"
13893 );
13894 }
13895
13896 // shares_per_poly < MIN_POLY_THRESHOLD with a valid quota -> should reject. There is no
13897 // matching upper-bound case: the threshold is a byte on the wire, so `MAX_POLY_THRESHOLD`
13898 // cannot be exceeded by anything a peer is able to send.
13899 for shares in [0, 1] {
13900 assert!(
13901 decode_and_check(&mgr, &offer(packed(8192, shares, 0))).is_none(),
13902 "should reject shares_per_poly {shares}"
13903 );
13904 }
13905
13906 // Valid params should still be accepted, and arrive intact — including the surplus, which
13907 // no other check looks at and which would therefore be free to go missing.
13908 let (accepted, batch_size) =
13909 decode_and_check(&mgr, &offer(packed(8192, 128, 37))).context("should accept valid params")?;
13910 assert_eq!(PixParams::try_new(8192, 128, 37, LOCAL_PIX_SUITE)?, accepted);
13911 assert_eq!(1, batch_size);
13912
13913 // Every surplus a byte can hold is legal.
13914 for surplus in [0, 1, u8::MAX] {
13915 assert_eq!(
13916 Some((PixParams::try_new(8192, 128, surplus, LOCAL_PIX_SUITE)?, 1)),
13917 decode_and_check(&mgr, &offer(packed(8192, 128, surplus)))
13918 );
13919 }
13920
13921 Ok(())
13922 }
13923
13924 /// The Exit refuses a client whose PIX curve suite is not the one this node was built for.
13925 ///
13926 /// The curve is a build-time property on both sides and is not negotiated, so the only two
13927 /// outcomes are "same suite" and "refused". This matters because it happens *before* the Exit
13928 /// commits: every later PIX field — each coefficient commitment, the proof of knowledge — is
13929 /// sized by the curve, so a mismatch that got past here would surface as undecodable Start
13930 /// traffic on a Session both sides believed they had established.
13931 ///
13932 /// The quota range is wide open, so nothing but the suite can be doing the rejecting.
13933 #[test_log::test(tokio::test)]
13934 async fn check_pix_params_must_refuse_a_foreign_curve_suite() -> anyhow::Result<()> {
13935 let mgr =
13936 SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(SessionManagerConfig {
13937 pix_config: IncomingSessionPixConfig {
13938 quota_range: 0..=10_000_000_000_000,
13939 ..Default::default()
13940 },
13941 ..Default::default()
13942 });
13943
13944 let offer = |suite: hopr_protocol_pix::PixSuite| StartInitiation {
13945 challenge: 0,
13946 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse().unwrap())),
13947 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13948 additional_data: into_initiation_additional_data(
13949 PixParams::try_new(8192, 128, 37, suite)
13950 .expect("test dimensions must be valid")
13951 .into_additional_data(0),
13952 ),
13953 };
13954
13955 let foreign = match LOCAL_PIX_SUITE {
13956 hopr_protocol_pix::PixSuite::BabyJubJub => hopr_protocol_pix::PixSuite::Secp256k1,
13957 hopr_protocol_pix::PixSuite::Secp256k1 => hopr_protocol_pix::PixSuite::BabyJubJub,
13958 };
13959
13960 assert!(
13961 decode_and_check(&mgr, &offer(foreign)).is_none(),
13962 "a client offering {foreign} must be refused by a {LOCAL_PIX_SUITE} Exit"
13963 );
13964 assert!(
13965 decode_and_check(&mgr, &offer(LOCAL_PIX_SUITE)).is_some(),
13966 "and the same dimensions on this build's own curve must still be accepted"
13967 );
13968
13969 // A suite identifier no curve claims is refused as well, rather than read as a third curve.
13970 let unknown = StartInitiation {
13971 challenge: 0,
13972 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse().unwrap())),
13973 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
13974 additional_data: into_initiation_additional_data(
13975 (0b11u64 << 62) | (8192u64 << 48) | (128u64 << 40) | (37u64 << 32),
13976 ),
13977 };
13978 assert!(
13979 decode_and_check(&mgr, &unknown).is_none(),
13980 "an unknown suite identifier must be refused"
13981 );
13982
13983 Ok(())
13984 }
13985
13986 /// Verifies that dispatching too many `UnverifiableShare` events closes the session.
13987 #[test_log::test(tokio::test)]
13988 async fn too_many_unverifiable_shares_closes_session() -> anyhow::Result<()> {
13989 let params = small_pix_params();
13990 let quota = pix_params_to_quota(¶ms);
13991 let ssa_gen_config = SsaGeneratorConfig {
13992 polynomials_per_ssa: 2,
13993 threshold: 2,
13994 surplus_shares: 1,
13995 };
13996
13997 let pix_toolbox = pix_toolbox_with_pool(
13998 SsaShareGenerator::new(ssa_gen_config).into(),
13999 SsaReconstructor::new(SsaReconstructorConfig::default()).into(),
14000 );
14001
14002 let mgr =
14003 SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(SessionManagerConfig {
14004 pix_config: IncomingSessionPixConfig {
14005 quota_range: quota..=quota,
14006 max_live_cycle_bytes: max_cycle_budget_for_quota(quota, 1),
14007 supervision: SupervisorConfig {
14008 max_deposit_wait: Duration::from_secs(1),
14009 ..Default::default()
14010 },
14011 ..Default::default()
14012 },
14013 ..Default::default()
14014 });
14015
14016 let mut bob_transport = MockMsgSender::new();
14017 bob_transport
14018 .expect_send_message()
14019 .returning(|_, _| Box::pin(async { Ok(()) }));
14020 bob_transport
14021 .expect_send_message()
14022 .returning(|_, _| Box::pin(async { Ok(()) }));
14023
14024 let (bob_sender, bob_handle) = mock_packet_planning(bob_transport);
14025 let (new_session_tx, new_session_rx) = futures::channel::mpsc::channel(1);
14026 let _notifications = tokio::spawn(async move {
14027 pin_mut!(new_session_rx);
14028 while let Some(_session) = new_session_rx.next().await {}
14029 });
14030 mgr.start(bob_sender.clone(), new_session_tx, Some(pix_toolbox.clone()), None)?;
14031
14032 let alice_pseudonym = HoprPseudonym::random();
14033
14034 mgr.handle_incoming_session_initiation(
14035 alice_pseudonym,
14036 StartInitiation {
14037 challenge: MIN_CHALLENGE,
14038 target: SessionTarget::TcpStream(SealedHost::Plain("127.0.0.1:80".parse()?)),
14039 capabilities: HoprSessionCapabilities(Capability::UsePIX.into()),
14040 additional_data: into_initiation_additional_data(params.into_additional_data(0)),
14041 },
14042 )
14043 .await?;
14044
14045 // Session is active
14046 assert_eq!(vec![alice_pseudonym], mgr.active_sessions());
14047
14048 let ssa_id = SsaId::new(alice_pseudonym, SsaIndex::new(1).expect("non-zero"));
14049
14050 // Exactly one event, reporting the absolute total the reconstructor would carry for a single
14051 // failed polynomial. There is no tolerance to exhaust first.
14052 let result = mgr
14053 .dispatch_pix_event(HoprSessionInPixEvent::UnverifiableShares {
14054 ssa_id,
14055 observed_total: 1,
14056 peer: crate::supervision::test_peer(),
14057 })
14058 .await;
14059 // Forwarding succeeds even for the event that closes: the supervisor decides, and it does so
14060 // after the send has been accepted.
14061 assert!(result.is_ok(), "dispatch_pix_event should not return an error");
14062
14063 // The supervisor's `Close` reaches the driver asynchronously, so the teardown it triggers
14064 // is observed rather than assumed.
14065 tokio::time::timeout(Duration::from_secs(1), async {
14066 while mgr.num_active_sessions() > 0 {
14067 tokio::time::sleep(Duration::from_millis(10)).await;
14068 }
14069 })
14070 .await
14071 .context("session was not closed after too many unverifiable shares")?;
14072
14073 assert!(mgr.active_sessions().is_empty());
14074
14075 bob_sender.close_channel();
14076 bob_handle.await??;
14077
14078 Ok(())
14079 }
14080
14081 /// Verifies that `allocate_session_slot` signals waiters via `.send(())` rather than
14082 /// dropping the senders. The `Ok(Ok(()))` path in `handle_ssa_request` must be live.
14083 #[test_log::test(tokio::test)]
14084 async fn allocate_session_slot_must_signal_waiters_not_cancel_them() -> anyhow::Result<()> {
14085 let session_id = HoprPseudonym::random();
14086 let mgr = SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::new(Default::default());
14087
14088 // Register a waiter before the slot is allocated
14089 let (tx, mut rx) = oneshot::channel::<()>();
14090 mgr.slot_allocated
14091 .lock()
14092 .unwrap_or_else(|e| e.into_inner())
14093 .entry(session_id)
14094 .or_default()
14095 .push(tx);
14096
14097 let (session_tx, _) = crossfire::mpsc::bounded_blocking_async::<ApplicationDataIn>(SESSION_FORWARD_CAPACITY);
14098
14099 // Allocate the slot — this should signal the waiter
14100 let guard = mgr.allocate_session_slot(
14101 session_id,
14102 SessionSlot {
14103 session_tx,
14104 routing_opts: DestinationRouting::Return(SurbMatcher::Pseudonym(session_id)),
14105 abort_handles: Default::default(),
14106 surb_mgmt: Arc::new(BalancerStateValues::from(SurbBalancerConfig::default())),
14107 surb_estimator: Default::default(),
14108 current_ssa_state: Default::default(),
14109 pix_supervisor: Default::default(),
14110 pix_egress_gate: Default::default(),
14111 pix_fill: Default::default(),
14112 returned_packets: Default::default(),
14113 cycle_budget: None,
14114 },
14115 );
14116 assert!(guard.is_some(), "slot allocation must succeed");
14117
14118 // The waiter should receive Some(()) — the direct signal, not Canceled
14119 assert!(
14120 matches!(rx.try_recv(), Ok(Some(()))),
14121 "waiter must be signaled with Ok(()), not canceled"
14122 );
14123
14124 guard.unwrap().commit();
14125
14126 Ok(())
14127 }
14128
14129 /// What the Exit reports back to its pool must be what the Exit sent.
14130 ///
14131 /// The pool answers over a channel and nothing stops it answering for an SSA that is not in the
14132 /// batch, or for another Session entirely. Those replies are dropped from the wire; they must be
14133 /// dropped from the Exit's own memory of the batch too. Keying whole replies by `id.ssa_index()`
14134 /// did not do that: a foreign reply naming an in-batch index displaced the good entry, and
14135 /// `DepositNeeded` then handed the pool back deposit data belonging to another Session.
14136 #[test_log::test(tokio::test)]
14137 async fn a_foreign_deposit_reply_must_not_displace_what_the_exit_sent() -> anyhow::Result<()> {
14138 use hopr_protocol_pix::{SsaGeneratorConfig, SsaReconstructorConfig};
14139
14140 let session_id: SessionId = HoprPseudonym::random();
14141 let other_session: SessionId = HoprPseudonym::random();
14142 let batch = [SsaIndex::MIN];
14143
14144 let (pix_toolbox, pix_events) = PixToolbox::new(
14145 SsaShareGenerator::new(SsaGeneratorConfig {
14146 polynomials_per_ssa: 2,
14147 threshold: 2,
14148 surplus_shares: 1,
14149 })
14150 .into(),
14151 Arc::new(SsaReconstructor::new(SsaReconstructorConfig::default())),
14152 );
14153 drop(pix_events);
14154 let _ = pix_toolbox;
14155
14156 let ssa_state = SessionSsaState::new(PixParams::try_new(2, 2, TEST_SURPLUS_SHARES, LOCAL_PIX_SUITE)?);
14157
14158 // What the pool sent: the right entry, then a same-index entry belonging to another Session.
14159 let replies = vec![
14160 PixDepositData {
14161 id: PixAddressId::new(&session_id, SsaIndex::MIN),
14162 data: b"mine".as_slice().into(),
14163 },
14164 PixDepositData {
14165 id: PixAddressId::new(&other_session, SsaIndex::MIN),
14166 data: b"someone else's".as_slice().into(),
14167 },
14168 ];
14169
14170 // Only the validated map is remembered, which is the whole point.
14171 let validated = deposit_data_for_batch(&session_id, &batch, replies)?;
14172 ssa_state.remember_sent_deposit_data(&validated);
14173
14174 let recalled = ssa_state.take_sent_deposit_data(&session_id, SsaIndex::MIN);
14175 assert_eq!(
14176 b"mine".as_slice(),
14177 recalled.data.as_ref(),
14178 "the Exit must recall the payload it actually sent"
14179 );
14180 assert_eq!(
14181 PixAddressId::new(&session_id, SsaIndex::MIN),
14182 recalled.id,
14183 "the id must name this Session and this index"
14184 );
14185
14186 // Consumed exactly once; a second cycle would see empty rather than a stale entry.
14187 assert!(
14188 ssa_state.take_sent_deposit_data(&session_id, SsaIndex::MIN).is_empty(),
14189 "taking must remove the entry"
14190 );
14191
14192 Ok(())
14193 }
14194
14195 /// Runs [`request_deposit_data`](SessionManager::request_deposit_data) against a pool that answers
14196 /// with exactly what `script` returns, in that order.
14197 ///
14198 /// `script` is handed the ids the Exit asked for and returns the replies the pool will send, one
14199 /// at a time — which is what makes reply *ordering* expressible, and ordering is the only thing
14200 /// the tests below differ on from a run that never had a problem.
14201 ///
14202 /// The pool holds its sender open after the script runs out, rather than dropping it and ending
14203 /// the stream. A pool that closes the channel ends the read all by itself, which would let a test
14204 /// pass on that alone — the reason the collection stopped is exactly what these tests are about,
14205 /// so the only things left to stop it are the ones under test.
14206 async fn request_deposit_data_answered_with(
14207 session_id: SessionId,
14208 batch: &[SsaIndex],
14209 script: impl FnOnce(Vec<PixAddressId>) -> Vec<PixDepositData> + Send + 'static,
14210 ) -> errors::Result<HoprPixDepositData> {
14211 let (pix_toolbox, pix_events) = PixToolbox::new(
14212 SsaShareGenerator::new(SsaGeneratorConfig {
14213 polynomials_per_ssa: 2,
14214 threshold: 2,
14215 surplus_shares: 1,
14216 })
14217 .into(),
14218 Arc::new(SsaReconstructor::new(SsaReconstructorConfig::default())),
14219 );
14220
14221 tokio::task::spawn(async move {
14222 let mut events = Box::pin(pix_events);
14223 if let Some(HoprSessionOutPixEvent::DepositDataRequest(request)) = events.next().await {
14224 let mut created = request.deposit_data_created;
14225 for reply in script(request.deposit_ids) {
14226 if created.send(reply).await.is_err() {
14227 return;
14228 }
14229 }
14230 // Held open, not dropped — see above. The task is torn down with the test's runtime.
14231 std::future::pending::<()>().await;
14232 }
14233 });
14234
14235 SessionManager::<UnboundedSender<(DestinationRouting, ApplicationDataOut)>>::request_deposit_data(
14236 &pix_toolbox,
14237 session_id,
14238 batch,
14239 )
14240 .await
14241 }
14242
14243 /// A reply the batch has no place for must not cost a reply that was asked for.
14244 ///
14245 /// The pool answers over one channel, and nothing about that channel separates a reply for this
14246 /// batch from another Session's. Reading a fixed number of *items* — one per requested SSA — let
14247 /// the first kind spend a slot belonging to the second, so a pool that answered every SSA and
14248 /// merely spoke out of turn lost the Session on the answer that got truncated.
14249 ///
14250 /// Ordering is the whole test: the same foreign reply arriving *after* the batch was complete
14251 /// never cost anything, because the collection had already stopped.
14252 #[test_log::test(tokio::test)]
14253 async fn a_foreign_reply_before_a_valid_one_must_not_truncate_the_batch() -> anyhow::Result<()> {
14254 let session_id: SessionId = HoprPseudonym::random();
14255 let other_session: SessionId = HoprPseudonym::random();
14256 let batch = [SsaIndex::MIN, SsaIndex::new(2).expect("non-zero")];
14257
14258 let collected = request_deposit_data_answered_with(session_id, &batch, move |ids| {
14259 std::iter::once(PixDepositData {
14260 id: PixAddressId::new(&other_session, SsaIndex::MIN),
14261 data: b"someone else's".as_slice().into(),
14262 })
14263 .chain(ids.into_iter().map(|id| PixDepositData {
14264 id,
14265 data: b"mine".as_slice().into(),
14266 }))
14267 .collect()
14268 })
14269 .await
14270 .context("a pool that answered every requested SSA must not fail the request")?;
14271
14272 let validated = deposit_data_for_batch(&session_id, &batch, collected)
14273 .context("every SSA in the batch must come out with an entry")?;
14274
14275 assert_eq!(batch.len(), validated.len(), "the whole batch must be covered");
14276 for index in batch {
14277 assert_eq!(
14278 b"mine".as_slice(),
14279 validated[&index].0.as_ref(),
14280 "index {index} must carry this Session's payload"
14281 );
14282 }
14283
14284 Ok(())
14285 }
14286
14287 /// A second answer for an SSA already answered must not cost an SSA still outstanding.
14288 ///
14289 /// Same failure as the foreign reply above, from the other direction: the repeat is a reply the
14290 /// batch has no place for, and counting items charged it against the one SSA that had not been
14291 /// answered yet. Which copy of the repeat wins is not what is under test here — that
14292 /// [`deposit_data_for_batch`] takes the later one is incidental — only that the reply after it
14293 /// still arrives.
14294 #[test_log::test(tokio::test)]
14295 async fn a_duplicate_reply_before_a_valid_one_must_not_truncate_the_batch() -> anyhow::Result<()> {
14296 let session_id: SessionId = HoprPseudonym::random();
14297 let batch = [SsaIndex::MIN, SsaIndex::new(2).expect("non-zero")];
14298
14299 let collected = request_deposit_data_answered_with(session_id, &batch, |ids| {
14300 let mut replies = ids
14301 .iter()
14302 .map(|id| PixDepositData {
14303 id: *id,
14304 data: vec![id.ssa_index().get() as u8].into_boxed_slice(),
14305 })
14306 .collect::<Vec<_>>();
14307 // The repeat of the first answer is inserted before the last one, so counting items
14308 // would have stopped on it and never read the SSA it was hiding.
14309 replies.insert(1, replies[0].clone());
14310 replies
14311 })
14312 .await
14313 .context("a pool that repeated itself but answered every SSA must not fail the request")?;
14314
14315 let validated = deposit_data_for_batch(&session_id, &batch, collected)
14316 .context("every SSA in the batch must come out with an entry")?;
14317
14318 assert_eq!(batch.len(), validated.len(), "the whole batch must be covered");
14319 for index in batch {
14320 assert_eq!(
14321 &[index.get() as u8],
14322 validated[&index].0.as_ref(),
14323 "index {index} must carry its own payload"
14324 );
14325 }
14326
14327 Ok(())
14328 }
14329
14330 /// Tolerating replies the batch has no place for must not become waiting for the deadline.
14331 ///
14332 /// Reading until every requested id is answered has no natural end when the pool never answers
14333 /// one, so the read is capped at twice the requested count. A pool that talks without ever
14334 /// closing the gap is cut off there and the request fails on the gap — which is the same
14335 /// verdict the deadline would reach, minus the [`DEPOSIT_DATA_REQUEST_TIMEOUT`] spent reaching
14336 /// it, with the Session held open throughout.
14337 #[test_log::test(tokio::test)]
14338 async fn a_pool_answering_only_noise_must_fail_on_the_cap_not_the_deadline() -> anyhow::Result<()> {
14339 let session_id: SessionId = HoprPseudonym::random();
14340 let other_session: SessionId = HoprPseudonym::random();
14341 let batch = [SsaIndex::MIN, SsaIndex::new(2).expect("non-zero")];
14342
14343 // Exactly the cap, and not one of them is for this Session.
14344 let noise = move |ids: Vec<PixAddressId>| {
14345 ids.iter()
14346 .flat_map(|id| {
14347 std::iter::repeat_n(
14348 PixDepositData {
14349 id: PixAddressId::new(&other_session, id.ssa_index()),
14350 data: b"someone else's".as_slice().into(),
14351 },
14352 2,
14353 )
14354 })
14355 .collect()
14356 };
14357
14358 // Well under the deadline: the point is that the cap ends the read, not the clock.
14359 let result = timeout(
14360 Duration::from_secs(1),
14361 request_deposit_data_answered_with(session_id, &batch, noise),
14362 )
14363 .await
14364 .context("the cap must end the read long before DEPOSIT_DATA_REQUEST_TIMEOUT")?;
14365
14366 assert!(
14367 matches!(
14368 result,
14369 Err(TransportSessionError::Manager(SessionManagerError::MissingDepositData(
14370 _
14371 )))
14372 ),
14373 "a pool that answered none of the requested SSAs must fail the request, got {result:?}"
14374 );
14375
14376 Ok(())
14377 }
14378
14379 /// The reply that lands exactly on the cap still counts.
14380 ///
14381 /// One reply earlier and the batch would be complete without the cap ever mattering, so this is
14382 /// the only arrangement that tells `take(2 * requested)` apart from `take(2 * requested - 1)`:
14383 /// every requested SSA is answered, but the last answer is the last reply the read will accept.
14384 #[test_log::test(tokio::test)]
14385 async fn the_last_reply_the_cap_allows_must_still_complete_the_batch() -> anyhow::Result<()> {
14386 let session_id: SessionId = HoprPseudonym::random();
14387 let other_session: SessionId = HoprPseudonym::random();
14388 let batch = [SsaIndex::MIN, SsaIndex::new(2).expect("non-zero")];
14389
14390 let collected = request_deposit_data_answered_with(session_id, &batch, move |ids| {
14391 // One unusable reply per requested id, then the answers themselves — `2 * requested`
14392 // replies in total, with the one that closes the gap sitting on the boundary.
14393 ids.iter()
14394 .map(|id| PixDepositData {
14395 id: PixAddressId::new(&other_session, id.ssa_index()),
14396 data: b"someone else's".as_slice().into(),
14397 })
14398 .chain(ids.iter().map(|id| PixDepositData {
14399 id: *id,
14400 data: vec![id.ssa_index().get() as u8].into_boxed_slice(),
14401 }))
14402 .collect()
14403 })
14404 .await
14405 .context("the answer landing on the cap must be read, not cut off by it")?;
14406
14407 let validated = deposit_data_for_batch(&session_id, &batch, collected)
14408 .context("every SSA in the batch must come out with an entry")?;
14409
14410 assert_eq!(batch.len(), validated.len(), "the whole batch must be covered");
14411 for index in batch {
14412 assert_eq!(
14413 &[index.get() as u8],
14414 validated[&index].0.as_ref(),
14415 "index {index} must carry its own payload"
14416 );
14417 }
14418
14419 Ok(())
14420 }
14421
14422 // -----------------------------------------------------------------------
14423 // PIX egress gate pressure
14424 // -----------------------------------------------------------------------
14425
14426 /// The egress path opens exactly one block episode per park, and closes it when the park ends.
14427 ///
14428 /// Asserted here rather than in the gate's own tests because the wiring is what can be wrong:
14429 /// the gate reports a verdict, and this is the only place that turns the verdict into an
14430 /// episode. A permit path that forgot to would leave `hopr_pix_gate_blocks_total` silent while
14431 /// egress was stalled -- exactly the fault the metric exists to surface.
14432 #[tokio::test]
14433 async fn a_parked_egress_packet_opens_and_closes_one_block_episode() -> anyhow::Result<()> {
14434 use crate::supervision::telemetry::probe;
14435
14436 probe::reset();
14437 // Strict prepay: the first packet is refused before anything is served.
14438 let gate = ServiceGate::new(0, 10);
14439
14440 let permit = acquire_egress_permit(
14441 Some(gate.clone()),
14442 DestinationRouting::Return(HoprPseudonym::random().into()),
14443 ApplicationDataOut::with_no_packet_info(ApplicationData::new(
14444 SESSION_APPLICATION_TAG,
14445 b"payload".as_slice(),
14446 )?),
14447 );
14448
14449 assert_eq!(
14450 1,
14451 probe::get("gate_blocks/predeposit_exhausted"),
14452 "the refusal must be counted as one episode beginning"
14453 );
14454 assert_eq!(
14455 0,
14456 probe::get("gate_block_observations/predeposit_exhausted"),
14457 "and must stay open while the packet is still parked"
14458 );
14459
14460 // Funding wakes the parked writer, which ends the episode.
14461 gate.release_service();
14462 timeout(Duration::from_secs(5), permit)
14463 .await
14464 .context("funding must wake the parked packet")?
14465 .context("a funded gate must admit it")?;
14466
14467 assert_eq!(1, probe::get("gate_blocks/predeposit_exhausted"), "still one episode");
14468 assert_eq!(
14469 1,
14470 probe::get("gate_block_observations/predeposit_exhausted"),
14471 "resuming must close the episode exactly once"
14472 );
14473 Ok(())
14474 }
14475
14476 /// Cancelling a parked packet closes its episode too.
14477 ///
14478 /// The case `Drop` exists for: a Session torn down while a writer is parked drops the permit
14479 /// future without ever resolving it, and a timer that only closed on the resume path would be
14480 /// left open for every Session that died blocked.
14481 #[tokio::test]
14482 async fn dropping_a_parked_egress_packet_closes_its_episode() -> anyhow::Result<()> {
14483 use crate::supervision::telemetry::probe;
14484
14485 probe::reset();
14486 let gate = ServiceGate::new(0, 10);
14487
14488 let permit = acquire_egress_permit(
14489 Some(gate.clone()),
14490 DestinationRouting::Return(HoprPseudonym::random().into()),
14491 ApplicationDataOut::with_no_packet_info(ApplicationData::new(
14492 SESSION_APPLICATION_TAG,
14493 b"payload".as_slice(),
14494 )?),
14495 );
14496 // Poll it once so the episode is actually begun inside the future.
14497 assert!(
14498 timeout(Duration::from_millis(50), permit).await.is_err(),
14499 "the packet must park rather than resolve"
14500 );
14501
14502 assert_eq!(1, probe::get("gate_blocks/predeposit_exhausted"));
14503 assert_eq!(
14504 1,
14505 probe::get("gate_block_observations/predeposit_exhausted"),
14506 "a cancelled park must still close its episode"
14507 );
14508 Ok(())
14509 }
14510
14511 /// A poisoned gate is counted as a refusal but opens no episode.
14512 #[tokio::test]
14513 async fn a_closed_gate_refuses_without_opening_an_episode() -> anyhow::Result<()> {
14514 use crate::supervision::telemetry::probe;
14515
14516 probe::reset();
14517 let gate = ServiceGate::new(10, 10);
14518 gate.poison();
14519
14520 let permit = acquire_egress_permit(
14521 Some(gate.clone()),
14522 DestinationRouting::Return(HoprPseudonym::random().into()),
14523 ApplicationDataOut::with_no_packet_info(ApplicationData::new(
14524 SESSION_APPLICATION_TAG,
14525 b"payload".as_slice(),
14526 )?),
14527 );
14528 assert!(permit.await.is_err(), "a poisoned gate must refuse");
14529
14530 assert_eq!(1, probe::get("gate_blocks/closed"));
14531 assert_eq!(
14532 0,
14533 probe::get("gate_block_observations/predeposit_exhausted"),
14534 "a gate that never resumes must not be timed"
14535 );
14536 Ok(())
14537 }
14538
14539 /// An un-gated Session pays nothing and is counted as nothing.
14540 #[tokio::test]
14541 async fn a_session_without_a_gate_is_not_measured() -> anyhow::Result<()> {
14542 use crate::supervision::telemetry::probe;
14543
14544 probe::reset();
14545
14546 acquire_egress_permit(
14547 None,
14548 DestinationRouting::Return(HoprPseudonym::random().into()),
14549 ApplicationDataOut::with_no_packet_info(ApplicationData::new(
14550 SESSION_APPLICATION_TAG,
14551 b"payload".as_slice(),
14552 )?),
14553 )
14554 .await
14555 .context("an un-gated packet passes straight through")?;
14556
14557 assert_eq!(
14558 std::collections::BTreeMap::new(),
14559 probe::non_zero(),
14560 "a Session that negotiated no PIX must contribute to no PIX aggregate"
14561 );
14562 Ok(())
14563 }
14564}