Skip to main content

hopr_transport_session/supervision/
mod.rs

1//! PIX supervision for incoming PIX-enabled sessions.
2//!
3//! This module implements the **Exit-side** supervision logic for sessions
4//! using the Protocol for Incentivization of eXits (PIX).  The Exit node
5//! runs a deterministic supervisor that tracks each *Secret Sharing Aggregate*
6//! (SSA) through a well-defined lifecycle, enforcing timeouts, deposit
7//! sufficiency, recovery progress, and fault tolerances.  Egress data
8//! packets are gated behind a concurrent `ServiceGate` that follows the
9//! front cycle: bounded predeposit service while it is unfunded and a
10//! ceiling-limited path after it funds.
11//!
12//! # Why supervision is needed
13//!
14//! PIX sessions use SSAs: cryptographic aggregates that distribute the
15//! cost of a deposit across many packets sent from the Entry to the Exit.
16//! Each SSA requires:
17//!
18//! 1. A **commitment** from the Entry (polynomial coefficients) that the Exit can verify.
19//! 2. A **deposit** to an SSA-specific on-chain address before the Exit fully trusts the SSA.
20//! 3. **Recovery** of the SSA shares from data packets as they arrive.
21//!
22//! Without supervision, a misbehaving or stalled Entry can hold a session
23//! slot indefinitely without ever funding or completing recovery — a
24//! resource-exhaustion vector.  The supervisor enforces hard per-SSA and
25//! per-session bounds so the Exit can reclaim resources deterministically.
26//!
27//! # Architecture
28//!
29//! The module is split into three components:
30//!
31//! | Component | File | Role |
32//! |---|---|---|
33//! | `SessionPixSupervisor` | `supervisor` | Pure state machine — no I/O, no async, no spawning.  Driven by explicit `Instant` timestamps and service-gate snapshots. |
34//! | `ServiceGate` | `gate` | Concurrent, lock-free egress gate.  Before the front funds: bounded predeposit budget.  After it funds: ceiling on packets served without that cycle's recovery progress.  Callers park on a generation-counter waker. |
35//! | `FillPlanner` | `fill` | Pure rate law for the Exit's own keep-alives.  Answers, once a second, how fast this node must send in order for the funded cycle to complete inside its deadline — and emits nothing when the answer has not meaningfully changed. |
36//! | Worker loop | `worker` | Per-session actor that bridges the pure supervisor to async reality.  Receives commands via a backpressured channel, manages the deadline and fill timers, applies gate actions synchronously, and forwards actions to the caller. |
37//!
38//! The [`SlotNotify`](crate::utils::SlotNotify) multi-waker primitive is shared from [`utils`] and used
39//! by `ServiceGate` to park and wake callers without a tokio dependency.
40//!
41//! ## The `SessionPixSupervisor` state machine
42//!
43//! The supervisor tracks each SSA through these phases:
44//!
45//! ```text
46//! RequestSsa  ──►  SsaRequestSent  ──►  AwaitingCommitment
47//!                                          ├── (CommitmentProgress arms/re-arms the re-request timer)
48//!                                          ├── (its expiry → RequestCommitmentRetransmission, bounded)
49//!                                          │
50//!                                     CommitmentVerified
51//!                                          │
52//!                                      AwaitingDeposit
53//!                                          │
54//!                                     DepositConfirmed (≥ expected)
55//!                                          │
56//!                                        Recovering
57//!                                          ├── (idle re-arms when no service)
58//!                                          ├── hard deadline is immutable
59//!                                          └── progress resets idle timer
60//!                                          │
61//!                                     Recovered
62//!                                          ├── bounded paid FIFO drain
63//!                                          ├── successor progress → handoff
64//!                                          └── diagnostic tombstone expiry → RetireSsa
65//! ```
66//!
67//! **Key deadlines** (all configurable via [`SupervisorConfig`]):
68//!
69//! * **Commitment timeout** — time from `SsaRequestSent` to `CommitmentVerified`.
70//! * **Commitment re-request** — time a *partially* delivered commitment may go quiet before the missing parts are
71//!   asked for again. The one deadline here that is not a deadline: it emits a repair and re-arms, never a close, and
72//!   the commitment timeout above remains the bound. It is armed only by `CommitmentProgress`, so a commitment nothing
73//!   was ever delivered for never asks — there is no scope for it to name. Bounded by count, at
74//!   [`MAX_COMMITMENT_RETRANSMISSIONS`](hopr_protocol_pix::MAX_COMMITMENT_RETRANSMISSIONS), which is what the Entry
75//!   will answer.
76//! * **Deposit timeout** — time from `CommitmentVerified` to a sufficient deposit.
77//! * **Recovery idle** — time without *any share arriving* while service is being consumed. **Service-gated**: if no
78//!   packets were served since the last progress snapshot, the timer re-arms instead of closing (prevents a
79//!   slow-but-honest Entry from being disconnected). Deliberately not "useful progress": a conforming Entry emits a
80//!   whole window's surplus in one run, none of it useful, and treating that as silence closes honest Sessions.
81//! * **Recovery hard deadline** — absolute per-SSA backstop, never extended. This is a resource guard (session slot +
82//!   reconstructor memory), not a liveliness mechanism.
83//!
84//! Both recovery clocks start when the cycle reaches the **paid transport front**, not when its
85//! deposit confirms. A batch is served in index order, and the final SURBs buffered for a recovered
86//! predecessor still carry that predecessor's shares. The predecessor keeps its original immutable
87//! hard clock while this bounded tail drains; the successor's clocks start only when the negotiated
88//! remainder is exhausted or funded successor progress proves the FIFO boundary. A queued cycle is
89//! therefore not charged for either its batch wait or its predecessor's pipeline delay.
90//!
91//! **Fault tracking** — an `UnverifiableShares` event closes the Session on arrival, with no
92//! tolerance to configure and no running totals to keep: the event means a polynomial's share set
93//! failed to open its commitment, which permanently dooms the cycle. Late fault reports against an
94//! already terminal SSA are absorbed. Progress is the one narrow exception: the sole immediate
95//! recovered predecessor may report its bounded, already-paid FIFO tail. See the configuration
96//! section below for why this is not a dial.
97//!
98//! **Rolling SSAs** — to maintain continuity, the supervisor requests the *next* batch when the
99//! current one is "almost recovered" (early threshold reached) or fully recovered, so the commitment
100//! and deposit work for the next cycle overlaps the tail of the current one. With a batch, only its
101//! **last** cycle may ask: the request flags are per-cycle, so without that gate every member would
102//! answer its own early signal with a batch of its own, compounding into `ssas_per_request` batches
103//! per batch. The last rather than the first, because a batch is served in index order and gating on
104//! the first would ask a whole batch too early. Retiring the cycle holding the gate hands it to the
105//! newest survivor, or a batch whose last cycle lost its deposit would strand with no successor.
106//!
107//! Allocation independently enforces the reservation: at most
108//! `MAX_OVERLAPPING_BATCHES` generations and
109//! `MAX_OVERLAPPING_BATCHES × ssas_per_request` live cycles may own reconstructor state. If the
110//! early signal arrives while both generations are still live, the supervisor records the owed
111//! request and retries it only after one generation releases its heavyweight cycle state. Recovered
112//! supervisor tombstones do not count because full recovery removes their heavyweight reconstructor
113//! state; one constant-bounded per-Session tail receipt is tracked separately.
114//!
115//! ## The `ServiceGate` — egress gating
116//!
117//! Every egress data packet from the Exit back to the Entry must pass
118//! through the `ServiceGate` via `acquire`:
119//!
120//! ### Pre-funding (predeposit)
121//!
122//! While the current front cycle is unfunded, a provisional budget lets the Exit answer the Entry
123//! for a bounded number of packets, so that the application is not held up for as long as an
124//! on-chain deposit takes to confirm. The initial front starts with this allowance; a successor gets
125//! a fresh allowance after its predecessor was funded and leaves the front, whether by recovery or a
126//! tolerated failure. A failed funded cycle has already cost the Entry its full deposit, and
127//! `max_failed_cycles` bounds how often that can happen. Losing an unfunded front does not mint
128//! another grant. The budget is
129//! `min(target_useful_shares - 1, max_predeposit_packets)`; at production dimensions the first term
130//! is in the hundreds of thousands, so the configured cap is what binds and the `min` only matters
131//! for the small dimensions used in tests.
132//!
133//! When the budget is exhausted, `acquire` parks the caller on a
134//! `SlotNotify` future. A concurrent `release_service`, `withhold_service`,
135//! `notify_progress`, or `poison`
136//! wakes all parkers.
137//!
138//! ### Strict prepay (`max_predeposit_packets = 0`)
139//!
140//! Zero is a supported setting, and it means the Exit serves nothing against any unfunded front:
141//! the first egress data packet parks, and the Entry has to commit and fund that cycle before a
142//! single payload byte flows back to it. The rule is restored on every paid rotation rather than
143//! applying only to the Session's first SSA.
144//!
145//! This does not deadlock, because nothing on the path to funding passes through the gate:
146//!
147//! * The `SsaRequest` goes out on the [`SessionManager`]'s own message sender, not the Session's gated sink.
148//! * The Entry's commitment travels Entry→Exit, and the deposit is on-chain.
149//! * The SURB-level keep-alive stream is deliberately left ungated, precisely so an exhausted budget cannot silence the
150//!   signal that keeps the Session fundable. Under strict prepay this carries more weight than it does at a non-zero
151//!   budget: with no egress at all, the keep-alives are the *only* traffic reaching the Entry, and therefore the only
152//!   thing resetting the idle timer on the Entry's own session slot. An operator who both sets this budget to zero and
153//!   disables `surb_balance_notify_period` gives the Entry nothing to stay alive on, and it will evict the Session
154//!   before a deposit can land.
155//!
156//! So the Entry can always be asked for a deposit and can always answer, whatever the budget is.
157//! What a zero budget costs is latency, not liveness: the application stalls until the deposit
158//! confirms, instead of proceeding optimistically. If the deposit never arrives, the deposit
159//! deadline closes the Session and `poison` fails the parked writer rather
160//! than leaving it pending.
161//!
162//! ### Post-funding (ceiling)
163//!
164//! Once the current front's deposit is confirmed and the supervisor emits `ReleaseService`, the
165//! gate flips to funded mode. It then enforces `max_served_without_progress`: a
166//! ceiling on how many packets may be served between SSA recovery progress
167//! events, as a defense-in-depth backstop even when the supervisor's
168//! service-gated idle timer is alive. Only progress from that funded front—or from its sole bounded
169//! recovered predecessor while the FIFO drains—emits `ProgressNotification` and resets the
170//! watermark; unfunded, off-front, older, or over-budget progress cannot buy service. A rotation to
171//! an unfunded successor emits `WithholdService` and restores the bounded allowance. A funded
172//! successor stays open, but another `ReleaseService` rebaselines its ceiling.
173//!
174//! The gate is implemented with lock-free atomics and CAS loops.  It uses
175//! the generation-counter `SlotNotify` to avoid the two classic
176//! race conditions of waker-vector approaches:
177//!
178//! 1. **Latent wake** — notification between future creation and first `poll()` is caught because the generation was
179//!    captured at creation time and compared on `poll()`.
180//! 2. **Spurious `Ready`** — a second `poll()` of an already-registered future re-checks the generation; if unchanged
181//!    it stays `Pending`.
182//!
183//! ## Fill — keeping a funded cycle on course
184//!
185//! Everything above is a rule for *stopping*. This is the one rule for going, and it exists because
186//! the rules for stopping cannot be met by an idle Session.
187//!
188//! ### What goes wrong without it
189//!
190//! A PIX Exit is paid per **return** packet. Each Exit → Entry packet spends one SURB carrying one
191//! share, and a funded cycle recovers — unlocks the deposit key it has already been paid for — only
192//! once `polys × (threshold + surplus)` of them have gone back. That is 327 680 packets at the shipped
193//! dimensions. Nothing in the protocol makes the *application* send them:
194//!
195//! * an idle VPN client sends nothing at all;
196//! * a tunnel between keystrokes sends a handful a minute;
197//! * any client that reads more than it writes returns fewer packets than it receives, by definition.
198//!
199//! Each of those leaves the Exit holding a cycle it was paid for and cannot complete.
200//! [`max_recovery_time`](SupervisorConfig::max_recovery_time) then closes the Session, and because
201//! the deposit address derives from the client's and the server's commitments *together*, the money
202//! is stranded rather than refunded. At production geometry the boundary is about 46 return packets
203//! per second averaged over a whole cycle — which is not a light client, it is a saturated one.
204//!
205//! So the Exit sends its own. The rate is planned once per `SAMPLING_INTERVAL`:
206//!
207//! ```text
208//! E          = polys × (threshold + surplus)                          packets in one cycle
209//! target     = the recovered predecessor's paid FIFO tail if there is one, else the earliest
210//!              live cycle that is both funded and clocked, else none → rate 0
211//! remaining  = (E − shares seen on the target) × (1 + loss_margin)
212//! finish_by  = target.hard_deadline − (1 − finish_fraction) × max_recovery_time
213//! required   = remaining / max(finish_by − now, SAMPLING_INTERVAL)     packets/s
214//! organic    = EMA over ORGANIC_WINDOW of the service gate's counter   packets/s
215//! fill       = clamp(required − organic, heartbeat, max_rate)
216//! ```
217//!
218//! Both of the middle two lines change for a *drain*, which is the same law over a Session that has
219//! already been closed — see "Draining a closed Session" below.
220//!
221//! The tail wins over the successor whenever it exists, for two reasons that point the same way: its
222//! SURBs sit ahead of the successor's in the Exit's own buffer, so packets sent now carry *its*
223//! shares; and it holds the binding deadline, since the successor's clocks are deliberately not armed
224//! until the tail is exhausted.
225//!
226//! ### Why it is the keep-alive message
227//!
228//! Fill reuses the Exit's SURB-level keep-alive rather than introducing a message of its own, and the
229//! two share one stream and one rate controller running at the faster of the two. Every shipped Entry
230//! already parses that message, already counts it as a consumed SURB so its balancer refills, and
231//! already credits it as service against the successor gate — so an Exit that fills needs nothing new
232//! from its peer. A new flag bit would have been worse than useless: keep-alive flag decoding used to
233//! be strict, so a bit an older Entry did not recognise made the whole message fail to parse.
234//!
235//! ### What bounds it
236//!
237//! A rule that *sends* is the one rule in this module that could be turned against the node running
238//! it, so it is bounded four times over:
239//!
240//! | Bound | What it stops |
241//! |---|---|
242//! | [`max_rate`](PixFillConfig::max_rate) | The instantaneous cost of one Session. Validated *upwards* too — `validate_incoming_session_pix_config` refuses a ceiling below the rate the widest accepted quota needs, because a filler that cannot finish is worse than none. |
243//! | [`min_surb_reserve`](PixFillConfig::min_surb_reserve) | Fill spending the SURBs the Session needs for its own replies. Not an optimisation: a return packet that finds no SURB holds every packet this node originates for `surb_resolution_wait`, so an over-eager filler stalls the Session it is saving. The SURB-level notification is exempt, being the message that asks for more. A ceiling rather than the figure used — the buffer it is measured against is the Entry's choice, so the effective reserve is never more than a quarter of the target the Entry announced. |
244//! | The stall rule | A doomed cycle, or a share-less SURB supply, burning `max_rate × max_recovery_time`. Once the target has not moved for [`max_recovery_idle`](SupervisorConfig::max_recovery_idle), fill drops to its heartbeat until the cycle moves again. |
245//! | The existing deadlines and the service gate | Unchanged. Fill is not a way around the backstop: an Exit with nothing to send strands exactly as it did before. |
246//!
247//! The stall rule cannot be delegated to `max_recovery_idle` itself, and that is worth being explicit
248//! about. The idle rule re-arms whenever no *gated* service was consumed since the last progress, and
249//! fill bypasses the gate exactly as the keep-alive stream it reuses does — so a Session whose only
250//! traffic is fill looks perfectly quiet to it and it never fires. The stall rule is the bound the
251//! gate-based one cannot supply.
252//!
253//! ### What it costs the client
254//!
255//! One cycle's quota per deadline while idle, and nothing at all while the application is busier than
256//! the required rate. The client accepts that as the price of a Session whose cycles complete, and
257//! funds successors as it would have.
258//!
259//! ### Draining a closed Session
260//!
261//! Fill answers a Session that has nothing to say. This answers one that is no longer there.
262//!
263//! A PIX Session can end at the Exit long before its funded cycle does, by three routes that all
264//! arrive at the same place: the session server closes the `HoprSession` (`WriteClosed`/`EmptyRead`),
265//! the operator calls `SessionManager::close_session`, or the peer sends a `SessionError`. Until
266//! now each of those tore the Session down at once, taking the action driver's commitment guards and
267//! the reconstructor state with it. If the Exit was already holding enough SURBs to finish the cycle,
268//! the deposit both sides paid for was stranded — the address derives from the two commitments
269//! together, so nothing refunds it.
270//!
271//! So the Exit keeps draining, under [`fill.drain_after_close`](PixFillConfig::drain_after_close),
272//! and only when the arithmetic says the drain can finish:
273//!
274//! ```text
275//! remaining = E − largest_shares_seen(target)
276//! drain iff estimated_surbs − min_surb_reserve ≥ remaining
277//! ```
278//!
279//! The target is the earliest live cycle, and only if it is both funded and clocked — the same cycle
280//! fill would have been working for. A Session with no funded cycle, or one whose front has already
281//! recovered and left a paid FIFO tail behind, has nothing a drain could buy and closes at once with
282//! `SessionPixCloseReason::SessionClosed`.
283//!
284//! Three things about that predicate are deliberate. The estimate is an **upper** bound — ring-buffer
285//! overwrites, store eviction and invalidated relayers are all unobserved — so `remaining` carries no
286//! loss margin to go with it; in-flight packets are already subtracted from the estimate, and full
287//! recovery needs fewer than `E` shares, so both errors point the conservative way. The reserve stays
288//! in force for the whole drain, because the failure it prevents is worse than a lost deposit: a
289//! return-routed packet that finds no SURB holds *every* packet this node originates for
290//! `surb_resolution_wait`. And the SURB-level notification — the one packet the reserve exempts — is
291//! switched **off** for the duration, since the reason for the exemption is that it asks for more
292//! SURBs on behalf of a Session that is gone: the Entry itself very likely still exists, but nothing
293//! is left there to act on the request, so no refill ever answers it.
294//!
295//! The rate law is the same one, with the two terms that assume a live Session taken out:
296//!
297//! ```text
298//! required = remaining × (1 + loss_margin) / SAMPLING_INTERVAL   -- no aim point; nothing to pace for
299//! fill     = clamp(required, heartbeat, max_rate)                -- no organic; nothing left to contribute
300//! ```
301//!
302//! Pacing a cycle to its aim point only makes sense while the Session is there to be paced, and
303//! subtracting organic egress only makes sense while an application is there to provide it. After the
304//! close neither is true — `served_total` is frozen for good — so a drain asks for its whole remainder
305//! at once and `max_rate` is what actually sets the pace. Leaving the subtraction in would have been
306//! worse than untidy: a client that hangs up on a cycle it has all but paid off leaves a small
307//! remainder against a still-high average, which suppresses the drain to its heartbeat for the whole
308//! of the averaging window — the one case the drain is most clearly worth doing. The stall rule is the
309//! one bound that does **not** step aside: it costs a progressing drain nothing and it is all that
310//! stands between a doomed one and `max_rate` sustained until a deadline fires.
311//!
312//! What a drain does not do is carry the Session on in any other respect. It retires every sibling
313//! cycle immediately (they are queued behind the target and can receive nothing while it drains) and
314//! it never asks for a successor, on any route into the request — there is no Session left to serve a
315//! successor's quota, so ordering one would commit the Entry to a deposit against a cycle that could
316//! never be served. It ends with `SessionPixCloseReason::Drained` when the cycle recovers, and with
317//! `RecoveryIdle` or `RecoveryDeadline` when it does not: `max_recovery_idle` stops being
318//! service-gated while draining, because a drained Session consumes no gated service ever again and
319//! the re-arm would otherwise never fire. The Session slot survives on its `time_to_idle`, which
320//! every progress event refreshes, so a productive drain keeps its slot and a stalled one is closed
321//! by the idle deadline long before the 180 s eviction — which remains the backstop.
322//!
323//! Two cases are deliberately excluded. **Idle eviction** does not drain — it reaches the cache's own
324//! listener rather than any of the close paths above — and that is the right answer rather than an
325//! omission: a filling Session's slot is refreshed by every progress event, so reaching the eviction
326//! at all means the cycle was already starved or stalled. A second explicit close of a *draining*
327//! Session forces the teardown for a different reason: it is the operator's way to overrule the
328//! drain. And a funded successor that has not yet reached the front, or a recovered predecessor's
329//! paid tail, is never drained for: a production SURB buffer is at most ten thousand deep and a cycle
330//! is 160 000–330 000 packets, so it could not cover a second one.
331//!
332//! ## The Worker — bridging pure logic to async
333//!
334//! `spawn_supervisor_worker` creates the `SessionPixSupervisor`,
335//! the `ServiceGate`, and a bounded async command channel.  It returns
336//! a `SessionPixSupervisorHandle` (cloneable, for sending events) and an
337//! `ActionRx` receiver (for driving actions).
338//!
339//! The worker loop:
340//!
341//! 1. Reads the earlier of the next deadline and the next fill tick from the supervisor.
342//! 2. If it has already passed, calls `handle_timers` immediately.
343//! 3. Otherwise, waits on the command channel with a timeout set to the remaining duration.
344//! 4. On command received → calls `handle_event` or `action_result`.
345//! 5. On timeout → calls `handle_timers`, which runs the deadline sweep first and then the fill tick if one is due. The
346//!    order matters: the sweep is what arms a promoted cycle's clocks and consumes a spent FIFO tail, so a tick that
347//!    ran first would plan against a lifecycle one interval out of date.
348//! 6. Applies gate-control actions immediately, then forwards all actions to the action channel (non-blocking
349//!    `try_send`).
350//!
351//! A Session with no funded cycle to fill for schedules no tick at all, so the loop's behaviour is
352//! byte-for-byte what it was before fill existed until the first deposit confirms.
353//!
354//! **Coalescing** — `ProgressNotification` actions are coalescible: when
355//! the action channel is transiently full, they are dropped rather than
356//! blocking or failing the worker. The gate has already consumed the notification locally, and the
357//! next forwarded notification replaces it for observers.
358//!
359//! All other actions (`RequestSsa`, `ReleaseService`, `WithholdService`, `RetireSsa`, `SetFillRate`,
360//! `Close`) are non-coalescible — if they cannot be delivered, the channel is
361//! genuinely wedged and the worker fails the session. `SetFillRate` belongs on that side of the line
362//! even though it arrives on a timer: the planner's hysteresis means it will not re-emit a value it
363//! believes is already applied, so a dropped change is not replaced by the next one — and the worst
364//! case is a dropped zero, which leaves a Session filling for a cycle that no longer exists.
365//!
366//! ## Integration with [`SessionManager`]
367//!
368//! ### Exit side (incoming sessions)
369//!
370//! When `handle_incoming_session_initiation` processes a session request
371//! with `Capability::UsePIX`:
372//!
373//! 1. Validates the offered PIX parameters (polys, threshold, quota range).
374//! 2. Spawns the supervisor worker via `spawn_supervisor_worker` (this emits the initial `RequestSsa` action and
375//!    creates the gate).
376//! 3. Reads the initial action, calls `send_ssa_request` on the wire, and notifies the supervisor of `SsaRequestSent`.
377//! 4. Stores the supervisor handle and gate in the session slot (via `OnceLock`).
378//! 5. Constructs the [`HoprSession`] — the egress adapter acquires the gate on every outgoing data packet.
379//! 6. After session publication, spawns the **action driver task** that receives actions from `ActionRx`. Gate control
380//!    has already happened in the worker; the driver performs I/O and records the gate-mode telemetry:
381//!
382//!    | Action | Behaviour |
383//!    |---|---|
384//!    | `RequestSsa` | Calls `send_ssa_request`, feeds back result to supervisor. Tracks each SSA in `SsaCommitmentGuard` for Drop-safe cleanup. |
385//!    | `RequestCommitmentRetransmission` | Calls `send_commitment_retransmission_request`, which reads the missing scope from the reconstructor and asks the Entry for it. Registers nothing and reports nothing back: it repeats on its own interval and the commitment deadline is the backstop. |
386//!    | `ReleaseService` | Worker calls `gate.release_service()`; driver records funded telemetry. |
387//!    | `WithholdService` | Worker calls `gate.withhold_service()`; driver records unfunded telemetry. |
388//!    | `ProgressNotification` | Worker calls `gate.notify_progress()`; no driver I/O. |
389//!    | `RetireSsa` | Calls `share_processor.retire_ssa`, aborts the deposit observer task. |
390//!    | `SetFillRate` | Applies the rate to the Session's shared keep-alive controller and records the gauge. |
391//!    | `Close` | Silences fill, poisons gate, retires all SSAs, publishes close metric, removes session slot. A close that ends a *drain* is an ordinary outcome rather than a failure: it is logged as information and the Entry is sent no failure notice, since the notice is return-routed and the Session it would report on is already gone — sending it would only spend a SURB out of a buffer that may be empty. |
392//!
393//! 7. PIX protocol events from the packet pipeline arrive via `dispatch_pix_event` and are forwarded to the supervisor
394//!    as `SessionPixEvent::RecoveryProgress`, `UnverifiableShares`, `AlmostRecovered`, or `Recovered`.
395//! 8. When a commitment becomes verifiable, a `PixDepositObserver` task loops on deposit confirmations, forwarding each
396//!    as `DepositConfirmed` to the supervisor.
397//! 9. `SessionPixEvent::SessionClosed` is the one event that does not come from `dispatch_pix_event`. The manager's own
398//!    close paths raise it, from `begin_surb_drain`, and the supervisor answers by either draining the funded cycle or
399//!    closing at once — see "Draining a closed Session" above.
400//!
401//! ### Entry side (outgoing sessions)
402//!
403//! The Entry does **not** run a supervisor — the Exit is authoritative for
404//! lifecycle decisions.  The Entry creates a session slot when
405//! `new_session()` succeeds, but the slot's `pix_supervisor` and
406//! `pix_egress_gate` remain unpopulated.  On receiving an `SsaRequest`
407//! from the Exit, the Entry generates its client commitment via the
408//! share generator, sends the commitment messages, and emits
409//! `ReadyToDeposit` so the caller can fund the deposit address.
410//!
411//! ## Lifecycle sketch (Exit side)
412//!
413//! ```text
414//! Session Initiation (Entry→Exit, with UsePIX flag)
415//!     │
416//!     ▼
417//! handle_incoming_session_initiation
418//!     │  validate PIX params
419//!     │  spawn supervisor (emits initial RequestSsa)
420//!     │  send SsaRequest on the wire
421//!     │  install gate & handle in slot
422//!     │  construct HoprSession (egress adaptor acquires gate)
423//!     │  spawn action driver
424//!     ▼
425//! ┌────────────────────────────────────────────────────┐
426//! │  Ongoing lifecycle (concurrent)                    │
427//! │                                                    │
428//! │  Entry → CommitmentVerified  → supervisor          │
429//! │  Entry → DepositConfirmed    → supervisor (via     │
430//! │                                PixDepositObserver) │
431//! │  Packets → share_processor   → RecoveryProgress    │
432//! │  Action: ReleaseService       → gate.release_service│
433//! │  Action: WithholdService      → gate.withhold_service│
434//! │  Action: ProgressNotification → gate.notify_progress│
435//! │  Action: RequestSsa (next)   → send on wire        │
436//! │  Action: RetireSsa           → reconstructor.retire│
437//! │  Action: Close               → poison + teardown   │
438//! └────────────────────────────────────────────────────┘
439//! ```
440//!
441//! # Configuration
442//!
443//! ## What each parameter prevents
444//!
445//! Every field of [`SupervisorConfig`], the failure it exists to stop, and what shipping default it
446//! carries. Nothing here is a preference: each one is the only thing standing between the Exit and a
447//! specific way of taking service without paying for it.
448//!
449//! | Parameter | Default | What it prevents |
450//! |---|---|---|
451//! | `ssas_per_request` | 1 | Nothing by itself — an exposure dial. With dynamic batching it is the largest batch the Exit may derive; otherwise it is the exact batch size. Raising it amortises the request round trip over several cycles, at the price of multiplying the *unfunded* exposure to that many quotas, and it scales the two deadlines below. |
452//! | `allow_dynamic_ssa_batches` | true | Lets a sub-range per-SSA offer satisfy `quota_range` by committing to the smallest batch whose total quota enters the range. Disable it to retain the fixed-batch, per-SSA admission rule. |
453//! | `max_failed_cycles` | 1 | An Entry losing one cycle per batch indefinitely while a single funded sibling holds the Session open. One loss is survivable, the second closes the Session. Only reachable above a batch of one, where the failing cycle is not always the last one standing. |
454//! | `max_ssa_delivery_time` | 20 s | An Entry that accepts a request and never delivers the commitment set, holding a session slot and a reconstructor cycle that can never be funded. |
455//! | `commitment_recommit_interval` | 3 s | A *single lost packet* costing the whole Session. A commitment set ships as hundreds of messages and is unusable until every one lands, so without this a drop stranded the cycle on the deadline above — after the Entry had already been told to fund it. The one parameter here that buys liveness rather than bounding an attack; it bounds nothing itself. |
456//! | `max_deposit_wait` | 60 s | An Entry that commits but never deposits — typically after it has already drawn the predeposit budget. |
457//! | `max_recovery_idle` | 60 s | An Entry, or a colluding first return relayer, consuming service while returning no shares. Service-gated, so a Session that is merely quiet is never punished. |
458//! | `max_recovery_time` | 4 h | A cycle that dribbles just enough progress to refresh the idle timer forever. A resource backstop for the slot and the reconstructor state, *not* the anti-drip rule. It must leave a whole cycle at the widest dimensions the node accepts — 655 360 packets of *full emission* at the defaults, the quantity `quota_range` prices — time to complete, or it closes honest Sessions instead. `validate_incoming_session_pix_config` judges that at `fill.max_rate` when fill is enabled (~61 min at the defaults) and at `MAX_ASSUMED_SESSION_PACKET_RATE` when it is not (~2 min). With fill it also sets the idle tariff; see "why four hours" on [`SupervisorConfig::max_recovery_time`]. |
459//! | `max_off_front_share_fraction` | 0.25 | An Entry spreading a batch's shares across all of its cycles, taking `ssas_per_request` quotas of service while completing none of them — and a cycle short of completion pays nothing at all. |
460//! | `min_share_order_sample` | 16384 | Convicting on a thin sample: the shares that legitimately cross a cycle boundary out of order while in flight. |
461//! | `max_predeposit_packets` | 10000 | Bounds what an Entry can extract from an unfunded front. Restored only after a paid front handoff; `0` means strict prepay on every rotation. |
462//! | `max_served_without_progress` | 2048 | Packets served with no share of *any* kind coming back — in *packets*, so unlike the idle timer the bound does not move with the Session's rate. Counts `shares_seen`, so a conforming Entry's surplus resets it; see below. |
463//! | `tombstone_retention_window` | 30 s | Bounds how long recovered-cycle diagnostics and observer ownership remain; the separately bounded FIFO-tail receipt may outlive it. |
464//! | `fill` | on | The one rule here that *sends* rather than stops: a funded cycle only recovers once its whole emission has ridden back to the Entry, so an idle Session strands the deposit it has already been paid. See [`PixFillConfig`]. |
465//! | `fill.drain_after_close` | on | A Session closed at the Exit mid-cycle stranding a deposit both sides have already paid for, while the Exit still holds the SURBs that would have completed it. Entered only when the estimated buffer covers the whole remainder, and bounded by everything `fill` is bounded by. |
466//!
467//! ## What is *not* here: the price of a cycle
468//!
469//! No parameter above says what a quota costs, and none should. The Exit's deposit pool is handed the
470//! [`DepositUpdated`](hopr_api::node::DepositUpdated) sender along with
471//! [`AgreedSsaQuota`] when the deposit address arrives, and it is the component
472//! that knows both the byte quota and the price it charges for one. It sends on that channel once the
473//! deposit clears that price; the supervisor's `DepositConfirmed` handler acts on the verdict rather
474//! than recomputing it. The Entry side mirrors this — its own strategy prices the `quota` carried by
475//! `PixEvent::NewDepositAddress` and decides what to pay.
476//!
477//! There was briefly a `min_deposit` here, and a per-SSA `expected_deposit` on the commitment event to
478//! go with it. Both are gone. The Session layer has no price for a byte to derive one from, and a
479//! configured floor would not be a backstop but a second authority behind the first: set above the
480//! pool's price it kills a cycle the pool has already been paid for, on `max_deposit_wait`, with
481//! nothing on either side able to break the tie.
482//!
483//! ## The one fault with no dial: unverifiable shares
484//!
485//! An `UnverifiableShares` report closes the Session immediately, and there is no configuration for
486//! it. This used to be a pair of tolerances, per-SSA and per-Session, both shipped at zero.
487//!
488//! Shares are not checked on arrival — the non-constant coefficient commitments that made per-share
489//! verification possible were dropped — so a report means a whole *polynomial's* share set failed to
490//! open its commitment. The reconstructor marks that part failed, releases its shares, and never
491//! clears the flag; the SSA is the sum of every polynomial's constant term, so from that moment the
492//! cycle cannot be reconstructed by any means and will never pay. A tolerance would therefore not buy
493//! recovery, only unpaid service — including in the case it looks written for, a false positive from
494//! a verification bug, where the part is just as permanently failed. Closing on the first report also
495//! caps the exposure at the `threshold` packets already served when the failure surfaces, rather than
496//! a multiple of it.
497//!
498//! What the tolerances bought instead was machinery: absolute cross-peer totals, per-SSA and
499//! per-Session running sums, and delta accounting against the maximum seen so far to keep concurrent
500//! ack batches from double-counting. None of it is observable at a limit of zero, since the first
501//! report ends the Session.
502//!
503//! ## Constraints between parameters
504//!
505//! [`validate_pix_supervision`] enforces, at config-load time and against the reconstructor config
506//! actually in use: `max_recovery_idle >= max_ack_await_time`; `tombstone_retention_window >=
507//! max_ack_await_time`; `max_recovery_idle < unused_verifier_lifetime`;
508//! `commitment_recommit_interval < max_ssa_delivery_time`; `ssas_per_request` in
509//! `1..=MAX_SSA_BATCH_SIZE`; both scaled deadlines under 24 h; non-zero durations; a share fraction in
510//! `0.0..=1.0`; and non-zero `max_served_without_progress`, `min_share_order_sample` and
511//! `max_failed_cycles`.
512//!
513//! For [`fill`](SupervisorConfig::fill) it enforces a non-zero `heartbeat` under the same 24 h cap, a
514//! finite `finish_fraction` in `(0, 1]`, a finite `loss_margin` in `[0, 1)`, a `max_rate` inside
515//! `1..=MAX_FILL_RATE`, and a non-zero `min_surb_reserve`. One further constraint cannot be decided
516//! here because it spans the quota: `validate_incoming_session_pix_config` requires `max_rate` to
517//! clear the rate a cycle of the *widest accepted quota* needs to finish inside
518//! `finish_fraction × max_recovery_time`, which is 64 packets/s at the shipped defaults.
519//!
520//! ### The surplus run, and why it no longer constrains anything
521//!
522//! Emission is round-robin over a window of up to `hopr_protocol_pix::SHARE_EMISSION_WINDOW` (256)
523//! polynomials advancing in lockstep. They reach `threshold` on the same pass and then take their
524//! surplus shares together, so every block ends with `surplus_shares × window` consecutive packets
525//! carrying no *useful* share — **4096 at the shipped dimensions**, against a
526//! `max_served_without_progress` of 2048.
527//!
528//! That used to be a live constraint on two parameters, and an unmeetable one: the ceiling and the
529//! idle timer both counted useful shares, so the run looked exactly like an Entry gone silent. The
530//! gate would park the writer partway through, a parked writer spends no SURBs, and the idle rule
531//! could not rescue it — its re-arm branch needs *no service consumed* since the last progress, and
532//! 2048 packets were. An honest Session closed with `RecoveryIdle` one emission block into a cycle.
533//!
534//! Both now read [`SsaRecoveryProgress::shares_seen`](hopr_protocol_pix::SsaRecoveryProgress::shares_seen),
535//! which counts every share the cycle accepts — surplus included, up to the negotiated budget per
536//! polynomial. Full cryptographic recovery no longer cuts that signal off: the reconstructor drops
537//! its heavy state but retains one shared per-polynomial remainder, and the supervisor keeps that
538//! recovered predecessor as the paid front until the tail is exhausted or successor progress proves
539//! the FIFO boundary. The run therefore resets the ceiling and refreshes the idle deadline exactly
540//! like the useful shares around it, even across recovery, so **neither parameter has to be sized
541//! against the dimensions any more.** What `max_served_without_progress` still bounds is genuine
542//! silence: packets served with no share of any kind coming back.
543//!
544//! The one dimension-dependent property that remains is not a supervisor parameter at all: a
545//! polynomial's whole emission, `threshold + surplus`, should fit one peer deferral bucket
546//! (`hopr_protocol_pix::MAX_DEFERRED_ACKS_PER_POLYNOMIAL`, 128), or acknowledgements arriving before
547//! the cycle's commitments install can be dropped. The shipped 96 fits; both halves are a byte wide
548//! on the wire, so the sum can reach 510. It is a sufficient condition rather than a required one —
549//! only the pre-install prefix is deferred — which is why it is asserted on the defaults rather than
550//! enforced.
551//!
552//! ## Worked example
553//!
554//! A profile of **~602 packets/s sustained per direction** and **5–6 s on-chain settlement** for an
555//! SSA deposit. At full `SESSION_MTU` segments that is ≈ 7 Mbps of Session
556//! data each way, and every return packet carries one SURB and one share.
557//!
558//! **Dimensions first**, because every supervisor value derives from them. `2048 × 64` with the
559//! derived surplus of 16 gives 131 072 useful shares out of 163 840 emitted, and a cycle of 163 840
560//! packets — about **4.5 min** at this rate. The quota is priced on all of them, surplus included, so
561//! it is `2048 × 80 × 1452 B` = **226.9 MiB**, which is exactly the bottom of the default
562//! `quota_range` (a quarter of the 907.5 MiB the default dimensions imply), so no range change is
563//! needed. The default `8192 × 64` would make that 18.1 min per cycle, which at 6 s settlement is a
564//! long time to leave one cycle's traffic unsettled for no benefit. Keep it a multiple of 256 so the
565//! emission window never narrows.
566//!
567//! | Parameter | Value | Why, at this profile |
568//! |---|---|---|
569//! | `PixGlobalConfig::num_ssa_parts` (Entry) | 2048 | 226.9 MiB quota, 4.5 min cycle; multiple of the emission window |
570//! | `PixGlobalConfig::ssa_part_size` (Entry) | 64 | Shipped threshold; with the surplus below, it is what fixes the quota |
571//! | `PixGlobalConfig::additional_shares` (Entry) | unset | Derives to 16 — a quarter of the threshold, i.e. a 1.25× surplus factor and a fifth of the quota. Setting it explicitly buys loss tolerance and charges the Entry for it. Keep `ssa_part_size + additional_shares` inside one deferral bucket (128) |
572//! | `ssas_per_request` | **1** | The 85 % early signal leaves a 24 627-packet runway — **41 s** — before the cycle drains, against 6 s of settlement plus a commitment round trip. There is nothing to amortise, and a batch of `n` would multiply the unfunded exposure to `n × 226.9 MiB` |
573//! | `max_ssa_delivery_time` | 20 s | 2048 commitments ship in ~71 forward packets, well under a second; the margin covers commitment generation |
574//! | `max_deposit_wait` | 30 s | 5× the 6 s settlement, leaving room for the Entry to notice `ReadyToDeposit`, submit, and the observer to see it |
575//! | `max_predeposit_packets` | 4096 | ~6.8 s of service, matching expected settlement rather than the deadline. This is the most exposed against an unfunded front after the initial grant or a paid handoff — 5.9 MB. Use `0` for strict prepay at every rotation; normal overlap usually funds the successor before it reaches the front |
576//! | `max_served_without_progress` | 2048 | Shipped value, and no longer dimension-dependent: the surplus run resets it like any other share, so this bounds genuine silence only |
577//! | `max_recovery_idle` | 60 s | Shipped value. Satisfies `>= max_ack_await_time` and `< unused_verifier_lifetime`. It no longer has to cover the surplus run — that resets it — so what it now implies is only that a Session returning *nothing at all* for a minute is closed |
578//! | `max_recovery_time` | 2 h | Resource backstop only. A cycle needs 272 s at full rate, so 2 h implies a floor of ~23 packets/s (~0.27 Mbps) — deliberately far below the idle rule, which is the instrument that should bind |
579//! | `max_off_front_share_fraction` | 0.25 | Shipped value. A conforming Entry sits near 0; two-way spreading is 0.5 |
580//! | `min_share_order_sample` | 16384 | Shipped value, and safe here: with emission clamped to one cycle the front cycle is essentially complete before any off-front progress is possible, so even a loss-doomed cycle peaks near 15 % against the 25 % ceiling |
581//! | `tombstone_retention_window` | 60 s | 2× the reconstructor's 30 s ack window |
582//! | `max_failed_cycles` | 1 | Shipped value, and inert at this batch size of one — the failing cycle is always the last one standing, which closes the Session first |
583//! | `fill.enabled` | true | The cycle is 163 840 return packets and the hard deadline is 2 h, so without fill any Session averaging under **23 packets/s of return traffic** over a cycle strands its deposit. Against the 602 packets/s this profile is sized for that sounds like a wide margin; it is not, because it is a *return* rate and any client that reads more than it writes falls under it |
584//! | `fill.finish_fraction` | 0.75 | Aims 30 min before the 2 h deadline, which is the margin for loss beyond `loss_margin`, for a mixnet delay spike, and for the successor's commitment and deposit round trip. A cycle that misses is worth nothing at all rather than nearly everything, so the last quarter of the budget is not spent |
585//! | `fill.loss_margin` | 0.05 | Fill counts packets sent; the cycle advances on shares that arrive. The Exit cannot observe the difference, so it sends this much extra and lets the per-second re-plan absorb whatever the real figure turns out to be |
586//! | `fill.max_rate` | 250 | An idle cycle here needs 163 840 x 1.05 / 5400 s = **32 packets/s**, so this is ~8x the requirement — headroom for a Session that fell behind, at a ceiling of ~2.9 Mbps. `validate_incoming_session_pix_config` enforces the floor against the *widest accepted quota*, which at the default `quota_range` is 128 packets/s |
587//! | `fill.heartbeat` | 60 s | The floor while organic egress covers the need. Not zero: the Entry's own idle eviction is refreshed by this traffic, and its balancer has no SURB-level report without it |
588//! | `fill.min_surb_reserve` | 500 | `SurbStoreConfig::distress_threshold`. A *ceiling*: the effective reserve is `min(500, announced_target / 4)`, floored at one, because the buffer it is measured against is sized by the Entry rather than here. At the 7 000-SURB balancer target below a quarter is 1 750, so 500 is what binds — leaving fill 93 % of the buffer and keeping the last 7 % for the application, which is the side that has something waiting on it. The derivation engages only on a Session whose Entry asked for under 2 000 SURBs, which without it could never be filled at all |
589//! | `fill.drain_after_close` | true | Shipped value, and inert at this profile's buffer depth: 7 000 SURBs less the 500 reserve is 4 % of a 163 840-packet cycle, so a Session closed at any point before the last 6 500 shares simply closes. What it does buy is the endgame — a client that hangs up in the final seconds of a cycle it has all but paid off no longer strands the whole deposit, and the drain then finishes in about 26 s at `max_rate` |
590//!
591//! What one cycle costs is not in this table, because it is not in this configuration: the 226.9 MiB
592//! quota is priced by the deposit pool, which is also what decides that a deposit has cleared it.
593//!
594//! Related settings outside [`SupervisorConfig`] that this profile also pins:
595//!
596//! | Parameter | Value | Why |
597//! |---|---|---|
598//! | `SurbStoreConfig::rb_capacity` | 100 000 | An overwritten SURB is a permanently lost share, so the buffer must clear the balancer's target with room for overshoot — 14× here |
599//! | `SurbBalancerConfig::target_surb_buffer_size` | 7 000 | ~11.6 s of return traffic at this rate; must cover the forward round trip or the Exit starves mid-cycle. It must also stay well *under* a cycle's emission — a SURB minted while no cycle is committed carries no share, so a buffer deeper than a cycle parks a run of share-less SURBs at the head of the Exit's FIFO that fill cannot get past. 7 000 against 163 840 is 4 %, so the run is a rounding error |
600//! | `SessionManagerConfig::maximum_surb_buffer_size` | 10 000 | Ceiling the balancer may be steered to |
601//! | `SsaReconstructorConfig::max_ack_await_time` | 30 s | Bounds how long an unacknowledged share is held; both `max_recovery_idle` and `tombstone_retention_window` must clear it |
602//! | `SsaReconstructorConfig::unused_verifier_lifetime` | 1800 s | Must exceed `max_recovery_idle`, so the supervisor gives up on a stalled cycle before the reconstructor reclaims what it would need to finish |
603//! | `SsaReconstructorConfig::early_recovery_threshold` | 0.85 | Sets the pipelining runway derived for `ssas_per_request` above — 24 627 packets, 41 s. Bounded below by `MIN_EARLY_RECOVERY_THRESHOLD` and equal to it today: the Entry's successor gate is computed at that floor, so a lower value asks for the next batch before any conforming Entry admits the request. Raising it shortens the runway |
604//! | `PixGlobalConfig::max_ssas_per_request` (Entry) | 2 | Must be ≥ every peer Exit's `ssas_per_request`; not negotiated, and an over-cap batch is refused in full |
605//! | `IncomingSessionPixConfig::max_live_cycle_bytes` | 3 GiB | Shipped value. At `2048 × 64 + 16` a Session reserves ≈20.5 MiB, so this profile admits ≈150 concurrent PIX Sessions rather than the ≈37 the default dimensions imply. It is the ceiling on live reconstructor state, and it — not `maximum_managed_sessions` — is what decides how many PIX Sessions this Exit accepts |
606
607use std::time::Duration;
608
609use hopr_api::{HoprBalance, OffchainPublicKey, types::internal::prelude::HoprPseudonym};
610use hopr_protocol_pix::{SsaId, SsaReconstructorConfig, SsaRecoveryProgress};
611
612use crate::errors::TransportSessionError;
613
614mod fill;
615mod gate;
616mod supervisor;
617// Bounded node-level accounting: the census, the event latch, and the per-Session handle.
618//
619// Deliberately not a `///` doc — see the identical note on `telemetry::pix`. An outer doc here is
620// merged with this module's `//!` header and resolved in *this* module's scope, breaking the
621// links the module wrote against its own.
622pub(crate) mod telemetry;
623mod worker;
624
625// ---------------------------------------------------------------------------
626// SupervisorConfig
627// ---------------------------------------------------------------------------
628
629/// Configuration for the `SessionPixSupervisor`.
630///
631/// Reachable from a node's configuration file as
632/// `incoming_session_pix_config.supervision`. The fields interact — with each other and with the
633/// reconstructor's own lifetimes — so a set of them is only meaningful as a whole; that is what
634/// [`validate_pix_supervision`] checks, and the node's config validator runs it at load time.
635///
636/// A worked set of values, and what each field defends against, is written out under
637/// "Configuration" in the `supervision` module documentation — the module is crate-private, so it is
638/// named rather than linked here.
639#[derive(Debug, Clone, PartialEq, smart_default::SmartDefault, serde::Serialize, serde::Deserialize)]
640#[serde(default, deny_unknown_fields)]
641pub struct SupervisorConfig {
642    /// Maximum number of SSAs the Exit may ask the Entry to commit to in one `SsaRequest`.
643    ///
644    /// With [`allow_dynamic_ssa_batches`](Self::allow_dynamic_ssa_batches), Session admission picks
645    /// the smallest batch no larger than this value whose total quota is inside the Exit's accepted
646    /// range. With dynamic batching disabled, this remains the exact batch size, preserving the
647    /// original fixed-batch behavior. Batching amortizes the request round trip over several deposit
648    /// cycles. It lives here rather than beside the other Exit settings because the supervisor acts
649    /// on the selected value: it allocates the indices and scales both deadlines below by this factor.
650    ///
651    /// Two things it costs, both linear in the value:
652    ///
653    /// * Up to two batches can own live reconstructor state — worst case ≈41 MiB per cycle at the profiled dimensions,
654    ///   per `hopr_protocol_pix::peak_cycle_bytes`. That cost is reserved up front against
655    ///   `IncomingSessionPixConfig::max_live_cycle_bytes`; the supervisor enforces the two-generation/full-cycle bound
656    ///   before allocating, so this value directly divides the number of PIX Sessions the node will admit.
657    /// * The unfunded exposure within one batch: every cycle is initially requested unfunded, so the commitment and
658    ///   deposit deadlines cover this many SSA quotas rather than one. The front-aware service gate still grants only
659    ///   the current cycle's bounded predeposit allowance.
660    ///
661    /// **Must not exceed the peer Entry's `max_ssas_per_request`.** The batch size is not negotiated
662    /// — the options word in the most significant 64 bits of `StartSession.additional_data` is fully
663    /// allocated (PIX dimensions and SURB balancer target), and its least significant 64 bits are not
664    /// yet used for this, so the Entry does not advertise its cap and the Exit cannot learn it. An Entry that finds a
665    /// batch too large refuses it in full and replies with an `UnacceptablePixParams` `SessionError`, which closes
666    /// the Session on both sides in about a round trip. Every such Session is still lost; the reply only makes the
667    /// failure immediate and attributable instead of surfacing as a commitment timeout.
668    ///
669    /// Range-checked by [`validate_pix_supervision`] and clamped where it is read, so it can never
670    /// exceed `MAX_SSA_BATCH_SIZE`.
671    ///
672    /// Default: 1, which reproduces the unbatched exchange byte-for-byte.
673    #[default(1)]
674    pub ssas_per_request: usize,
675
676    /// Whether Session admission may derive a smaller SSA batch from the Entry's offered quota.
677    ///
678    /// When enabled, the Exit tries batch sizes from one through
679    /// [`ssas_per_request`](Self::ssas_per_request) and selects the first whose total quota is inside
680    /// `IncomingSessionPixConfig::quota_range`. If none fits, the Session is refused with
681    /// `UnacceptablePixParams`. Choosing the smallest fit limits Entry work, deposits, Exit memory,
682    /// and the chance of exceeding the Entry's unadvertised `max_ssas_per_request` cap.
683    ///
684    /// When disabled, the offered per-SSA quota itself must be inside `quota_range`, and the Exit
685    /// requests exactly `ssas_per_request` SSAs, matching the behavior before dynamic batching.
686    ///
687    /// Default: `true`.
688    #[default(true)]
689    pub allow_dynamic_ssa_batches: bool,
690
691    /// Cycles this Session may lose without recovering them before it is closed. The next one closes
692    /// it.
693    ///
694    /// Counted over the whole Session, not per batch, because a retired cycle leaves no trace on its
695    /// siblings: without a cumulative count an Entry can lose one cycle per batch forever while a
696    /// single funded sibling keeps the Session alive, paying for a fraction of what it is served.
697    ///
698    /// Only reachable with [`ssas_per_request`](Self::ssas_per_request)` > 1`. At the shipping batch
699    /// of one the failing cycle is always the last one standing, and that closes the Session before
700    /// this is ever consulted — so nothing changes at the defaults.
701    ///
702    /// A recovered cycle never counts: it leaves through tombstone expiry, not through the failure
703    /// path.
704    ///
705    /// Default: 1 — one lost cycle is survivable, which is what the retire-and-hand-on-the-successor
706    /// -gate path exists for, and a second is not.
707    #[default(1)]
708    pub max_failed_cycles: usize,
709
710    /// Maximum time to wait for the SSA to be fully committed.
711    ///
712    /// Together with [`max_deposit_wait`](Self::max_deposit_wait) this bounds how long a Session may
713    /// be served unincentivized: the Entry has to be able to deliver its SSA commitment before it
714    /// can be asked to pay for it.
715    ///
716    /// Multiplied by [`ssas_per_request`](Self::ssas_per_request) when the deadline is armed: a batch
717    /// asks the Entry for that many commitment sets in answer to one request, and all of their clocks
718    /// start together, so holding each to a single cycle's budget would fail an Entry that is
719    /// answering in order.
720    ///
721    /// Default: 20 s.
722    #[default(Duration::from_secs(20))]
723    #[serde(with = "humantime_serde")]
724    pub max_ssa_delivery_time: Duration,
725
726    /// How long a *partially* delivered commitment may go quiet before the missing parts are
727    /// re-requested.
728    ///
729    /// The Entry's answer to an `SsaRequest` is not one message but hundreds of them, and the Exit
730    /// can use none of it until every one has landed — so a single lost packet used to cost the whole
731    /// Session on [`max_ssa_delivery_time`](Self::max_ssa_delivery_time), typically after the Entry
732    /// had already been told to fund the cycle's deposit address. This is the timer that repairs it:
733    /// on expiry the Exit asks the Entry to re-send the polynomial commitments it never received.
734    ///
735    /// An *idle* timer, armed and re-armed by each arriving commitment message, so it measures
736    /// silence rather than elapsed time and the first ask cannot land in the middle of the burst.
737    /// It is only ever armed once something has arrived: a commitment nothing was delivered for has
738    /// no scope to name, and there is nothing the Exit could ask for.
739    ///
740    /// It bounds nothing on its own — `max_ssa_delivery_time` remains the absolute deadline, so the
741    /// unincentivized exposure of a cycle is unchanged however often this fires. How many asks a
742    /// cycle makes is bounded by count rather than by this interval:
743    /// [`MAX_COMMITMENT_RETRANSMISSIONS`](hopr_protocol_pix::MAX_COMMITMENT_RETRANSMISSIONS), which
744    /// is also exactly how many the Entry will answer, so shortening this buys promptness and never
745    /// a flood of wasted packets.
746    ///
747    /// Default: 3 s — well clear of the time a full burst takes to arrive, and short enough that a
748    /// repair completes several times over inside the delivery deadline.
749    #[default(Duration::from_secs(3))]
750    #[serde(with = "humantime_serde")]
751    pub commitment_recommit_interval: Duration,
752
753    /// Maximum time to wait for a deposit after the commitment is verifiable.
754    ///
755    /// Also multiplied by [`ssas_per_request`](Self::ssas_per_request). Each cycle's clock starts at
756    /// its own commitment, which staggers them somewhat — but a batch's commitments arrive
757    /// back-to-back while an Entry funding them serially finishes the last one that many deposits
758    /// later, so the stagger does not cover it.
759    ///
760    /// Default: 60 s.
761    #[default(Duration::from_secs(60))]
762    #[serde(with = "humantime_serde")]
763    pub max_deposit_wait: Duration,
764
765    /// Maximum idle time during recovery when service is being consumed.
766    ///
767    /// Gated on service consumption — if no packets were served, the timer
768    /// re-arms instead of closing.
769    ///
770    /// Default: 60 s.
771    #[default(Duration::from_secs(60))]
772    #[serde(with = "humantime_serde")]
773    pub max_recovery_idle: Duration,
774
775    /// Absolute per-SSA recovery deadline (immutable once set).
776    ///
777    /// This is a **resource backstop** (session slot + reconstructor memory),
778    /// not the anti-drip mechanism. The service-gated idle rule is.
779    ///
780    /// ## Why four hours
781    ///
782    /// It has to leave a whole cycle at the dimensions the node accepts time to complete, but that
783    /// floor is far below four hours: ~61 min with fill at its default ceiling, ~2 min without fill
784    /// at `MAX_ASSUMED_SESSION_PACKET_RATE`. A cycle cut
785    /// short is worth nothing, since the SSA is the sum of every polynomial's constant term, so the
786    /// margin is cheap.
787    ///
788    /// What sets the value is the rest of what it does. With fill enabled it is the idle tariff: an
789    /// idle Session's cycle is finished at `finish_fraction` of it, three hours, which needs 64
790    /// packets/s for the widest accepted quota. And it keeps this instrument far enough out that
791    /// [`max_recovery_idle`](Self::max_recovery_idle) is what actually binds.
792    ///
793    /// The clock starts when the cycle reaches the paid transport front. That is deliberately later
794    /// than merely becoming the earliest unrecovered record: the predecessor's buffered surplus tail
795    /// can still occupy ~4096 packets, over a minute for a slow Session, and remains bounded by the
796    /// predecessor's original hard clock. Starting the successor at the FIFO boundary prevents that
797    /// pipeline delay from being charged twice. A funded cycle that reaches the front and is never
798    /// served is still caught.
799    ///
800    /// `HoprProtocolConfig::validate` checks this against the dimensions the node will actually
801    /// accept, so a raised `quota_range` that outgrows it is refused at load rather than discovered
802    /// one closed Session at a time.
803    ///
804    /// Default: 4 hours.
805    #[default(Duration::from_secs(14400))]
806    #[serde(with = "humantime_serde")]
807    pub max_recovery_time: Duration,
808
809    /// Largest share of recovery progress that may land on a cycle *other* than the one at the front
810    /// of the batch, as a fraction of all progress since that cycle took the front.
811    ///
812    /// ## What it defends
813    ///
814    /// A batch only de-risks the Exit if its cycles are served one at a time, so that recovering the
815    /// first pays for the traffic that earned it. An Entry that instead spread a batch's shares across
816    /// all `ssas_per_request` cycles could take `ssas_per_request × quota` of service while leaving
817    /// every cycle short of recovery — and a cycle short of recovery pays nothing at all, since the SSA
818    /// is the sum of *every* polynomial's constant term. That turns the batch knob from cost-neutral
819    /// into an n-fold amplifier of unpaid traffic.
820    ///
821    /// Nothing else sees it. The service gate and [`max_recovery_idle`](Self::max_recovery_idle) both
822    /// measure the *absence* of progress, and a spreading Entry makes progress on every cycle
823    /// continuously — including the front one, whose idle timer it therefore keeps refreshing. It
824    /// simply never finishes one.
825    ///
826    /// ## Why a fraction and not a count
827    ///
828    /// Out-of-order *arrival* is not an Entry property. SURBs cross the mixnet outbound and their
829    /// acknowledgements cross it again inbound, so arrival order is a permutation of emission order
830    /// whose width is roughly `delay_range × packet rate`. An absolute tolerance would therefore have
831    /// to be re-derived every time the mixer is reconfigured, and — being cumulative — would drift
832    /// into closing honest Sessions given enough cycle boundaries.
833    ///
834    /// A ratio has neither problem: a permutation of bounded width is a vanishing *fraction* of a
835    /// growing window, so widening `delay_range` cannot move it, and the denominator grows with the
836    /// numerator so nothing accumulates. It is also independent of packet **rate** (both terms scale
837    /// together, so unlike a wall-clock bound it implies no throughput floor), of **loss** (which hits
838    /// every cycle alike), and of **`surplus_shares`** — which matters even now that the surplus is
839    /// negotiated rather than Entry-private. The Exit knows the budget, not the spend: how many of a
840    /// cycle's emitted shares turn out to be useful depends on the loss the Entry actually met, so no
841    /// expected shares-per-packet ratio follows from the parameter. Comparing the batch's cycles
842    /// against each other cancels it.
843    ///
844    /// ## The value
845    ///
846    /// A conforming Entry sits at ~0: with emission clamped to one cycle
847    /// ([`hopr_protocol_pix::SHARE_EMISSION_WINDOW`]) the only off-front progress is boundary
848    /// reordering. Spreading across `n` cycles gives `(n − 1) / n` — 0.5 at two cycles, 0.67 at three.
849    /// 0.25 separates those with room on both sides.
850    ///
851    /// It bounds rather than eliminates: an Entry may divert up to this fraction and stay legal, so
852    /// worst-case unpaid exposure becomes ~`1 / (1 − f)` cycles instead of `n`. Alternating — serving
853    /// the front cycle honestly, then dumping on later ones — buys nothing, because the accounting only
854    /// resets when the front cycle *completes*, which means it was paid for.
855    ///
856    /// Default: 0.25.
857    #[default(0.25)]
858    pub max_off_front_share_fraction: f64,
859
860    /// Progress that must accumulate before
861    /// [`max_off_front_share_fraction`](Self::max_off_front_share_fraction) is judged at all, in useful
862    /// shares.
863    ///
864    /// A sample-size floor, not a tolerance: it does not bound how much a cheat can extract — the
865    /// fraction does that — it only refuses to convict on thin evidence.
866    ///
867    /// Sized by the one case that would otherwise produce a false positive: a cycle made
868    /// *unrecoverable* by loss. A polynomial that loses more than `surplus_shares` of its shares can
869    /// never reach its threshold, so its cycle stops progressing while later ones continue, and the
870    /// off-front fraction climbs to 1.0. That cycle is not something to close a Session over — it stops
871    /// progressing, its service-gated [`max_recovery_idle`](Self::max_recovery_idle) deadline expires,
872    /// it is retired, and the accounting resets on the new front. The sample floor only has to outlast
873    /// that window: ~181 shares/s, the per-Session rate this was sized at, over 60 s is ~10 900, and
874    /// 16 384 covers up to ~273 shares/s.
875    ///
876    /// Unlike an absolute tolerance this does not have to grow with Session length or track the mixer
877    /// configuration — it is a floor on evidence, evaluated once and then continuously.
878    ///
879    /// Very large values can make this rule effectively inert for a Session. To disable share-order
880    /// enforcement intentionally, prefer
881    /// [`max_off_front_share_fraction = 1.0`](Self::max_off_front_share_fraction). No fixed upper bound
882    /// is enforced because the accounting window can span multiple successor batches before the front
883    /// advances.
884    ///
885    /// Default: 16384 shares.
886    #[default(16384)]
887    pub min_share_order_sample: u64,
888
889    /// Cap on the provisional predeposit service budget.
890    ///
891    /// This buys the application an exchange while the current front cycle's deposit confirms on
892    /// chain; it is not needed for the Session to become fundable, so it is a
893    /// latency-versus-exposure dial rather than a correctness requirement. The initial front starts
894    /// with this allowance, and it is restored for an unfunded successor after a paid front leaves
895    /// the front, whether by recovery or a tolerated failure. Retiring an unfunded front does not
896    /// grant it again.
897    ///
898    /// **Zero is supported and means strict prepay**: the Exit answers nothing until a sufficient
899    /// deposit is confirmed. Everything on the path to funding — the `SsaRequest`, the Entry's
900    /// commitment, the deposit itself, and the SURB keep-alive stream — bypasses the egress gate, so
901    /// a zero budget stalls the application without ever stalling the funding handshake. Deliberately
902    /// *not* rejected by [`validate_pix_supervision`] for that reason, unlike
903    /// [`max_served_without_progress`](Self::max_served_without_progress), where zero would wedge a
904    /// Session that has already paid.
905    ///
906    /// The effective budget is `min(target_useful_shares - 1, max_predeposit_packets)`, so this value
907    /// is the binding one at any realistic set of PIX dimensions.
908    ///
909    /// Default: 10000 packets.
910    #[default(10000)]
911    pub max_predeposit_packets: u64,
912
913    /// Maximum packets served without a single share coming back, before the gate blocks further
914    /// service as a defence-in-depth backstop.
915    ///
916    /// A ceiling enforced by `ServiceGate::acquire` while the current front is funded. A
917    /// [`crate::HoprSessionInPixEvent::RecoveryProgress`] snapshot resets it only for that funded
918    /// front. The sole recovered predecessor temporarily remains that paid front while its
919    /// per-polynomial negotiated remainder drains; progress on an unfunded, queued, older, or
920    /// over-budget cycle does not grant service.
921    ///
922    /// Those events now follow `shares_seen` rather than `useful_shares`, which is what makes a flat
923    /// 2048 safe at any dimensions. Keyed on useful shares, this had to exceed
924    /// `surplus x min(polys, SHARE_EMISSION_WINDOW)` — 4096 at the shipped dimensions, twice
925    /// this value — because a conforming Entry emits a whole window's surplus in one contiguous run
926    /// during which no share is useful. The gate parked the writer partway through, and a parked
927    /// writer spends no SURBs, so nothing could arrive to release it. See
928    /// [`SsaRecoveryProgress::shares_seen`](hopr_protocol_pix::SsaRecoveryProgress::shares_seen).
929    ///
930    /// What it bounds now is genuine silence, and that is rate-independent: it is a count of
931    /// *packets*, so unlike [`max_recovery_idle`](Self::max_recovery_idle) it implies no throughput
932    /// floor.
933    ///
934    /// Default: 2048 packets.
935    #[default(2048)]
936    pub max_served_without_progress: u64,
937
938    /// How long to retain recovered-SSA diagnostic records and observer ownership.
939    ///
940    /// Must be >= the reconstructor's `max_ack_await_time`. The compact paid-tail receipt is separate
941    /// and may outlive this record; expiring a tombstone therefore does not truncate a FIFO drain.
942    ///
943    /// Default: 30 s.
944    #[default(Duration::from_secs(30))]
945    #[serde(with = "humantime_serde")]
946    pub tombstone_retention_window: Duration,
947
948    /// How the Exit keeps a funded cycle on course for its own deadline when the application is
949    /// quiet.
950    ///
951    /// Every other field above is a rule for *stopping*. This one is the single rule for *going*, and
952    /// it exists because the deadlines cannot be met by an idle Session: a funded cycle only recovers
953    /// once its whole emission has ridden back to the Entry, so at production dimensions a Session
954    /// averaging under ~46 return packets/s over a cycle loses its deposit to
955    /// [`max_recovery_time`](Self::max_recovery_time) — which is every idle or light client.
956    ///
957    /// See [`PixFillConfig`] for what it costs and what it is bounded by.
958    #[serde(default)]
959    pub fill: PixFillConfig,
960}
961
962/// How the Exit fills the return direction of a funded PIX cycle when the application will not.
963///
964/// # Why the Exit has to send anything at all
965///
966/// A PIX Exit is paid per *return* packet. Each Exit → Entry packet spends one SURB carrying one
967/// share, and a funded cycle only recovers — only unlocks the deposit it has already been paid — once
968/// `polys × (threshold + surplus)` of them have gone out. That figure is 327 680 at the shipped
969/// dimensions. Nothing in the protocol makes the *application* send them: an idle VPN client, a
970/// tunnel between keystrokes, or a client that simply reads more than it writes all leave the Exit
971/// holding a funded cycle that will never complete. `max_recovery_time` then closes it, and because
972/// the deposit address derives from client and server commitments together, the money is stranded
973/// rather than refunded.
974///
975/// So the Exit sends its own keep-alives — "fill" — at whatever rate the remaining emission needs to
976/// clear the deadline, minus whatever the application is already providing. The mechanism is
977/// deliberately the existing SURB-level keep-alive stream rather than a new message: every shipped
978/// Entry already parses it, already counts it as a consumed SURB (so its balancer refills), and
979/// already credits it as service against the successor gate.
980///
981/// # What it costs the client
982///
983/// One cycle's quota per deadline while idle, and nothing at all while the application is busier than
984/// the required rate — [`heartbeat`](Self::heartbeat) is what fill drops to when organic egress
985/// already covers the need. The client accepts this as the price of a Session that completes its
986/// cycles; it funds successors exactly as it would have.
987///
988/// # What bounds it
989///
990/// Four separate things, because a rule that *sends* is the one rule in this module that could be
991/// turned against the node running it:
992///
993/// * [`max_rate`](Self::max_rate) caps the instantaneous rate.
994/// * [`min_surb_reserve`](Self::min_surb_reserve) stops fill from spending the SURBs the Session needs for its own
995///   replies, as a ceiling on a reserve derived per Session from the Entry's announced buffer target.
996/// * The stall rule holds fill at the heartbeat once the target cycle stops making progress, so a doomed cycle or a
997///   share-less SURB supply cannot burn `max_rate × max_recovery_time` SURBs.
998/// * The existing deadlines and the service gate are unchanged, and remain the backstop for a client that stops
999///   supplying SURBs at all.
1000#[derive(Debug, Clone, PartialEq, smart_default::SmartDefault, serde::Serialize, serde::Deserialize)]
1001#[serde(default, deny_unknown_fields)]
1002pub struct PixFillConfig {
1003    /// Whether the Exit originates fill keep-alives at all.
1004    ///
1005    /// Disabling it restores the behaviour of an Exit that only ever answers: funded cycles then
1006    /// complete if and only if the application returns enough traffic, and the ones that do not are
1007    /// closed by `max_recovery_time` with their deposits stranded. That is a defensible choice only
1008    /// for a deployment whose clients are known to saturate their Sessions.
1009    ///
1010    /// Default: `true`.
1011    #[default(true)]
1012    pub enabled: bool,
1013
1014    /// The floor fill rate: one packet per this period, sent even when nothing needs sending.
1015    ///
1016    /// Fill never drops to silence while a funded cycle is in flight, for two reasons that have
1017    /// nothing to do with the deadline. The Entry's own idle eviction is refreshed by this traffic,
1018    /// and a Session whose Exit has gone quiet is one whose SURB balancer has no level report to act
1019    /// on. It is also what makes the ramp observable: a rate that is *only* ever the computed one
1020    /// cannot be told apart from a planner that has stopped planning.
1021    ///
1022    /// Expressed as a period rather than a rate because it is far below one packet per second and
1023    /// [`FillRate`] carries its own unit, so `1 / 60 s` is exact rather than a rounded fraction.
1024    ///
1025    /// Default: 60 s.
1026    #[default(Duration::from_secs(60))]
1027    #[serde(with = "humantime_serde")]
1028    pub heartbeat: Duration,
1029
1030    /// Fraction of [`SupervisorConfig::max_recovery_time`] by which a cycle should be finished.
1031    ///
1032    /// The planner aims at `hard_deadline − (1 − finish_fraction) × max_recovery_time` rather than at
1033    /// the deadline itself, so the last quarter of the budget is margin: for the loss the
1034    /// [`loss_margin`](Self::loss_margin) does not cover, for a mixnet delay spike, and for the
1035    /// round trip the successor's commitment and deposit still need after this cycle recovers. Aiming
1036    /// at the deadline exactly would make every cycle a photo finish, and a cycle that misses is
1037    /// worth nothing at all rather than nearly everything.
1038    ///
1039    /// Default: 0.75.
1040    #[default(0.75)]
1041    pub finish_fraction: f64,
1042
1043    /// Fraction by which the remaining emission is inflated to cover return-path loss.
1044    ///
1045    /// Fill counts packets it *sends*; the cycle advances on shares that *arrive*. The difference is
1046    /// return-path loss, which the Exit cannot observe directly — it sees the reconstructor's progress
1047    /// and nothing about the packets that produced none. Rather than infer a loss rate from a signal
1048    /// that also moves for a dozen other reasons, the planner simply sends this much extra and lets
1049    /// the ordinary re-planning each second absorb whatever the real figure turns out to be.
1050    ///
1051    /// Default: 0.05.
1052    #[default(0.05)]
1053    pub loss_margin: f64,
1054
1055    /// Ceiling on the fill rate, in packets per second.
1056    ///
1057    /// This is the value that decides how much bandwidth an idle Session may cost, and it has to
1058    /// clear the floor a cycle actually needs: the largest quota this node accepts, spread over
1059    /// `finish_fraction × max_recovery_time`. `validate_incoming_session_pix_config` enforces exactly
1060    /// that, so a cap set below it is refused at load rather than discovered one stranded deposit at a
1061    /// time. It counts the cap only up to
1062    /// `MAX_ASSUMED_SESSION_PACKET_RATE`, since fill faster
1063    /// than a Session is assumed to carry finishes nothing sooner.
1064    ///
1065    /// Default: 250 packets/s, which is ~2.9 Mbps of Session data at full `SESSION_MTU` segments and
1066    /// about four times the 64 packets/s the shipped defaults require.
1067    #[default(250)]
1068    pub max_rate: u32,
1069
1070    /// Ceiling on the SURBs that must remain estimated-available before a fill packet is emitted.
1071    ///
1072    /// Fill is the one egress on the Exit that has no application waiting on it, so it is the one
1073    /// that must yield. Spending the last SURBs on fill is not merely wasteful: a return packet with
1074    /// no SURB to ride holds *every* packet this node originates for the resolver's
1075    /// `surb_resolution_wait`, so an over-eager filler stalls the Session it is trying to save. The
1076    /// SURB-level notification is exempt — it is the signal that asks for more SURBs — but the
1077    /// heartbeat is not, being fill at its floor rather than a different message.
1078    ///
1079    /// A ceiling rather than the figure used, because the two sides of the comparison are chosen by
1080    /// different nodes: the reserve is measured against the Exit's estimate of its own SURB buffer for
1081    /// the Session, and how deep that buffer is is the *Entry's* decision, announced with its
1082    /// initiation. The effective reserve is therefore never more than a quarter of the Entry's
1083    /// announced buffer target, floored at one. Without that, an operator sizing this against a
1084    /// production buffer would also be deciding — unintentionally — that every Session whose Entry
1085    /// asks for a shallower one can never be filled at all: the estimate never reaches the reserve,
1086    /// every fill packet is withheld, and the funded cycle strands on
1087    /// [`max_recovery_time`](SupervisorConfig::max_recovery_time).
1088    ///
1089    /// Default: 500, matching `SurbStoreConfig::distress_threshold`. Against the 7 000-SURB balancer
1090    /// target of the worked profile a quarter is 1 750, so the configured value is what binds there
1091    /// and the derivation only engages on a Session whose Entry asked for less than four times it.
1092    #[default(500)]
1093    pub min_surb_reserve: u64,
1094
1095    /// Whether a Session closed at this Exit keeps draining its buffered SURBs into the funded cycle.
1096    ///
1097    /// A Session can end at the Exit long before its funded cycle does: the session server closes it,
1098    /// the operator calls `SessionManager::close_session`, or the peer reports a `SessionError`. The
1099    /// deposit that cycle was paid is only released once its whole emission has ridden back to the
1100    /// Entry, and the address it sits at derives from both nodes' commitments — so a cycle abandoned
1101    /// midway strands money both sides have already parted with rather than refunding it. With this
1102    /// on, the Exit answers such a close by keeping the keep-alive stream running until the cycle
1103    /// recovers, spending SURBs it is already holding on shares it has already been paid for.
1104    ///
1105    /// Deliberately narrow, and inert unless [`enabled`](Self::enabled) is set — a drain is fill,
1106    /// planned by the same rate law and carried by the same stream, so a disabled planner emits no
1107    /// rate to drain at. Three further conditions bound what it can cost:
1108    ///
1109    /// * it is entered only when the Exit's *estimated* SURB buffer, net of
1110    ///   [`min_surb_reserve`](Self::min_surb_reserve), covers the cycle's whole remaining emission — a drain that runs
1111    ///   out partway spends the reserve and recovers nothing, which is worse than not starting;
1112    /// * [`max_rate`](Self::max_rate) and that same reserve bound it exactly as they bound ordinary fill, and the
1113    ///   SURB-level notification — fill's one reserve-exempt packet — is switched off for the duration, since it asks
1114    ///   for more SURBs on behalf of a Session that is gone: the Entry itself may well still be there, but nothing is
1115    ///   left to act on the request, so no refill answers it;
1116    /// * [`max_recovery_idle`](SupervisorConfig::max_recovery_idle) and
1117    ///   [`max_recovery_time`](SupervisorConfig::max_recovery_time) end it whether or not it succeeds.
1118    ///
1119    /// Setting it to `false` restores the immediate teardown on every close path, byte for byte.
1120    ///
1121    /// Default: `true`.
1122    #[default(true)]
1123    pub drain_after_close: bool,
1124}
1125
1126/// A rate at which the Exit originates PIX fill keep-alives.
1127///
1128/// A count over a period rather than a float, because it maps one-to-one onto
1129/// `RateController::set_rate_per_unit` and therefore says exactly what the stream will do. The
1130/// heartbeat is one packet per minute; expressing that as `0.0166… packets/s` and converting back
1131/// would be a rounding error in a value whose whole purpose is to be small and exact.
1132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1133pub struct FillRate {
1134    /// Packets to emit per [`per`](Self::per).
1135    pub packets: u32,
1136    /// The period [`packets`](Self::packets) is measured over. Never zero.
1137    pub per: Duration,
1138}
1139
1140impl FillRate {
1141    /// Emit nothing. The rate a Session is left at once it has no funded cycle to fill for.
1142    pub const ZERO: Self = Self {
1143        packets: 0,
1144        per: Duration::from_secs(1),
1145    };
1146
1147    /// `packets` per second.
1148    pub const fn per_second(packets: u32) -> Self {
1149        Self {
1150            packets,
1151            per: Duration::from_secs(1),
1152        }
1153    }
1154
1155    /// One packet per `period` — the heartbeat form.
1156    pub const fn once_per(period: Duration) -> Self {
1157        Self {
1158            packets: 1,
1159            per: period,
1160        }
1161    }
1162
1163    /// Whether this rate emits nothing.
1164    pub const fn is_zero(&self) -> bool {
1165        self.packets == 0
1166    }
1167
1168    /// This rate in packets per second.
1169    ///
1170    /// Zero for a zero period as well as for zero packets: a period that cannot be divided by is not
1171    /// an infinite rate, it is a rate this planner refuses to have produced. Both routes by which a
1172    /// heartbeat reaches the planner guard against one — `validate_pix_supervision` rejects a zero for
1173    /// a config that was validated, and `SessionManager::new` raises it to a positive floor for the
1174    /// programmatic ones that never are — so this arm is a last line rather than the thing doing the
1175    /// work.
1176    pub fn as_packets_per_sec(&self) -> f64 {
1177        let per = self.per.as_secs_f64();
1178        if self.packets == 0 || per <= 0.0 {
1179            0.0
1180        } else {
1181            self.packets as f64 / per
1182        }
1183    }
1184}
1185
1186// ---------------------------------------------------------------------------
1187// PixParams
1188// ---------------------------------------------------------------------------
1189
1190/// PIX dimensions agreed upon during session negotiation.
1191///
1192/// Re-exported rather than defined here: this supervisor once carried its own `polys`/`threshold`
1193/// pair, which meant the same thing as the one a Session offers but named it differently and could
1194/// not be handed across the boundary without a field-by-field copy. `target_useful_shares` moved
1195/// onto the shared type with it.
1196///
1197/// That shared type is now the one the two nodes actually negotiate, down to the byte layout it is
1198/// packed into — so it carries a third field, the surplus. The supervisor never puts surplus into
1199/// [`target_useful_shares`](PixParams::target_useful_shares), because it advances no payment, but it
1200/// does use `threshold + surplus` to cap the recovered FIFO tail independently of the reconstructor.
1201pub use hopr_protocol_pix::PixParams;
1202
1203// ---------------------------------------------------------------------------
1204// SessionPixEvent
1205// ---------------------------------------------------------------------------
1206
1207/// Events consumed by the [`SessionPixSupervisor`](supervisor::SessionPixSupervisor).
1208#[derive(Debug, Clone)]
1209pub enum SessionPixEvent {
1210    /// The initial or next SSA request was successfully sent on the wire.
1211    SsaRequestSent(SsaId<HoprPseudonym>),
1212    /// Part of an SSA commitment arrived, but the set is still incomplete.
1213    ///
1214    /// Reported per message, which is what makes the re-request timer an *idle* timer: the burst
1215    /// keeps pushing it out, and it only expires once delivery has actually stalled. It is also the
1216    /// only evidence that the Entry answered at all, which is what distinguishes a cycle worth
1217    /// re-asking for from one where nothing was ever delivered and there is no scope to name.
1218    CommitmentProgress(SsaId<HoprPseudonym>),
1219    /// A verifiable commitment was installed in the reconstructor.
1220    CommitmentVerified(SsaId<HoprPseudonym>),
1221    /// The Exit's deposit pool reported a sufficient deposit for this SSA.
1222    ///
1223    /// The `amount` is the pool's, for the record and for the one check the supervisor still makes on
1224    /// it — that it is non-zero. Sufficiency itself is the pool's verdict; see
1225    /// `SessionPixSupervisor::on_deposit_confirmed`.
1226    DepositConfirmed {
1227        ssa_id: SsaId<HoprPseudonym>,
1228        amount: HoprBalance,
1229    },
1230    /// The deposit observer channel closed without delivering a confirmation.
1231    DepositObserverClosed(SsaId<HoprPseudonym>),
1232    /// Recovery progress snapshot from the reconstructor.
1233    RecoveryProgress(SsaRecoveryProgress<HoprPseudonym>),
1234    /// Early-recovery threshold reached.
1235    AlmostRecovered(SsaId<HoprPseudonym>),
1236    /// Full SSA recovery completed.
1237    Recovered(SsaId<HoprPseudonym>),
1238    /// Absolute per-SSA unverifiable-share count observation.
1239    UnverifiableShares {
1240        ssa_id: SsaId<HoprPseudonym>,
1241        observed_total: u64,
1242        /// Relayer that carried the offending share. Diagnostic only — it reaches the close log and
1243        /// nothing else, since the cycle is condemned whoever relayed the share.
1244        peer: Box<OffchainPublicKey>,
1245    },
1246    /// The session layer closed this Session at the Exit.
1247    ///
1248    /// The one event here that does not come from the packet pipeline: it is raised by the manager's
1249    /// own close paths — the session server's `WriteClosed`/`EmptyRead`, an operator's
1250    /// `close_session`, or a peer `SessionError` — rather than by an observation about a cycle.
1251    ///
1252    /// `drainable_surbs` is the Exit's *estimate* of the SURBs it may still spend, already net of the
1253    /// fill reserve. An estimate, and an upper bound at that: ring-buffer overwrites, store eviction
1254    /// and invalidated relayers are all unobserved. That is why the supervisor compares it against
1255    /// the whole remaining emission rather than a discounted one, and why the deadlines remain the
1256    /// backstop for a drain admitted on a figure that turns out to have been optimistic.
1257    SessionClosed { drainable_surbs: u64 },
1258}
1259
1260// ---------------------------------------------------------------------------
1261// SessionPixAction
1262// ---------------------------------------------------------------------------
1263
1264/// Actions emitted by the [`SessionPixSupervisor`](supervisor::SessionPixSupervisor) for the caller to execute.
1265#[derive(Debug, Clone)]
1266pub enum SessionPixAction {
1267    /// Request one or more new SSAs with the given dimensions.
1268    ///
1269    /// Always non-empty, and always contiguous ascending indices. The whole batch travels in a single
1270    /// `SsaRequest` — one message, one `params` field — because every SSA in a Session shares the
1271    /// negotiated dimensions. Carrying the set rather than one id is what keeps the batch atomic:
1272    /// the carrier either puts all of them on the wire or none, and a partial failure releases every
1273    /// registration it made.
1274    RequestSsa {
1275        ssa_ids: Vec<SsaId<HoprPseudonym>>,
1276        params: PixParams,
1277    },
1278    /// Ask the Entry to re-send the parts of one SSA's commitment that never arrived.
1279    ///
1280    /// Emitted when a partially delivered commitment has been quiet for
1281    /// [`SupervisorConfig::commitment_recommit_interval`], and repeated on that interval until the
1282    /// commitment verifies or the absolute delivery deadline closes the Session. The scope is not
1283    /// carried here: what is missing is the reconstructor's to say, and it can change between this
1284    /// action being emitted and being carried out, so the carrier reads it at the point of sending.
1285    ///
1286    /// Unlike [`RequestSsa`](Self::RequestSsa) this asks for nothing new and its failure is not
1287    /// terminal — the delivery deadline remains the backstop, so a dropped ask costs one interval.
1288    RequestCommitmentRetransmission {
1289        ssa_id: SsaId<HoprPseudonym>,
1290        params: PixParams,
1291    },
1292    /// Put the service gate in funded mode for the front cycle, or rebaseline a funded successor.
1293    ReleaseService,
1294    /// Return the service gate to an unfunded successor's predeposit allowance after a paid handoff.
1295    WithholdService,
1296    /// Notifies the gate that the funded front made recovery progress, resetting its
1297    /// served-without-progress ceiling.
1298    ProgressNotification,
1299    /// Close the session with the given reason.
1300    Close(SessionPixCloseReason),
1301    /// Retire a previously-used SSA from the reconstructor (idempotent).
1302    /// Emitted when an SSA's tombstone period expires so mid-session state
1303    /// does not accumulate.
1304    RetireSsa(SsaId<HoprPseudonym>),
1305    /// Set the rate at which the Exit originates its own fill keep-alives.
1306    ///
1307    /// Emitted only when the planned rate actually changes — see `fill::FillPlanner`, which is
1308    /// private to this crate — so the action stream carries decisions rather than a per-second
1309    /// heartbeat of its own.
1310    ///
1311    /// Deliberately **not** coalescible, unlike [`ProgressNotification`](Self::ProgressNotification).
1312    /// A dropped notification is replaced by the next one within an acknowledgement batch; a dropped
1313    /// rate change is not replaced at all, because the planner's hysteresis means it will not
1314    /// re-emit a value it believes is already applied. Dropping a drop to zero is the worst case:
1315    /// the Session would keep spending SURBs on a cycle that no longer exists. At not more than one
1316    /// per second against a 64-deep channel, treating a failure to deliver it as a wedged channel is
1317    /// the correct reading.
1318    SetFillRate(FillRate),
1319}
1320
1321// ---------------------------------------------------------------------------
1322// SessionPixCloseReason
1323// ---------------------------------------------------------------------------
1324
1325/// Internal close reasons emitted by the supervisor.
1326///
1327/// These are mapped to public [`ClosureReason`](crate::types::ClosureReason) by the caller.
1328#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display)]
1329#[strum(serialize_all = "snake_case")]
1330pub enum SessionPixCloseReason {
1331    /// The commitment delivery deadline expired.
1332    CommitmentTimeout,
1333    /// The deposit deadline expired without a sufficient deposit.
1334    DepositTimeout,
1335    /// The deposit observer channel closed without delivering a confirmation.
1336    DepositObserverClosed,
1337    /// Service was consumed but no useful progress was made — service-gated idle.
1338    RecoveryIdle,
1339    /// The per-SSA hard recovery deadline expired.
1340    RecoveryDeadline,
1341    /// A polynomial's share set failed to open its commitment.
1342    ///
1343    /// There is no threshold behind this: the first report closes the Session, and the reported
1344    /// `observed_total` is diagnostic only. See the module documentation for why a tolerance would
1345    /// buy nothing — a failed part can never be reconstructed, so the cycle can never pay.
1346    UnverifiableShares,
1347    /// Too much of the batch's recovery progress landed on cycles behind the front one — see
1348    /// [`SupervisorConfig::max_off_front_share_fraction`].
1349    BatchServedOutOfOrder,
1350    /// A counter observation decreased (protocol violation).
1351    CounterRegression,
1352    /// Internal inconsistency (e.g., mismatched target, event on unknown SSA).
1353    InvalidTransition,
1354    /// The SSA set drained (all SSAs expired/recovered without a successor).
1355    NoSsaRemaining,
1356    /// The supervisor action driver failed or was dropped.
1357    SupervisorUnavailable,
1358    /// The Session was closed at the Exit with nothing left worth draining.
1359    ///
1360    /// Not a fault: it is the ordinary end of a Session whose funded cycle the Exit cannot finish out
1361    /// of the SURBs it holds — or which had no funded cycle at all. What it reports is that the
1362    /// Session ended because it was closed, rather than because one of the deadlines above expired.
1363    SessionClosed,
1364    /// The funded cycle recovered after the Session had already been closed at the Exit.
1365    ///
1366    /// The good outcome of a drain, and the reason the drain exists: the deposit the Entry paid for
1367    /// that cycle is released rather than stranded. A drain that instead runs out of SURBs or of
1368    /// shares closes with [`RecoveryIdle`](Self::RecoveryIdle) or
1369    /// [`RecoveryDeadline`](Self::RecoveryDeadline), which is why there is no third reason here.
1370    Drained,
1371}
1372
1373// ---------------------------------------------------------------------------
1374// Re-exports from submodules
1375// ---------------------------------------------------------------------------
1376
1377// `GateClosed` is re-exported despite nothing in the crate naming it today: it is the error type of
1378// both [`ServiceGate::acquire`] and [`ServiceGate::try_acquire_sync`], and an error a caller cannot
1379// name is one it cannot match on or downcast to. `manager` currently only forwards it into an
1380// `io::Error`, which is why the import reads as unused.
1381#[allow(unused_imports)]
1382pub use gate::{GateBlockReason, GateClosed, GateVerdict, ServiceGate};
1383/// The state machine itself, for benchmarks only.
1384///
1385/// `supervisor` is otherwise private on purpose: the worker owns the only instance that exists in
1386/// production, and driving one by hand means supplying the `Instant` and served-count arguments
1387/// that the worker derives from the gate. A benchmark is exactly the case where doing so is the
1388/// point — [`SessionPixSupervisor::handle_event`] runs once per acknowledgement batch, which is the
1389/// highest-rate input this module has, and it cannot be measured from outside the crate otherwise.
1390/// Same gate as [`SessionManager::pre_populate_session`](crate::SessionManager::pre_populate_session).
1391#[cfg(any(feature = "benchmark", test))]
1392pub use supervisor::SessionPixSupervisor;
1393pub use worker::{ActionRx, SessionPixSupervisorHandle, spawn_supervisor_worker};
1394
1395// ---------------------------------------------------------------------------
1396// Validation
1397// ---------------------------------------------------------------------------
1398
1399/// Range-checks the two [`PixFillConfig`] fractions, ahead of anything that computes with them.
1400///
1401/// Extracted from [`validate_pix_supervision`] because it is not the first validator to see them.
1402/// `validate_incoming_session_pix_config` derives the fill floor a cycle of the widest accepted
1403/// quota needs, and `SessionManager::start` calls it *before* `validate_pix_supervision` — as does
1404/// `HoprProtocolConfig`, where one is a field validator and the other a schema-level one. That
1405/// derivation runs `Duration::mul_f64(finish_fraction)`, which panics on a `NaN`, a negative, or a
1406/// product the monotonic clock cannot represent. A single `finish_fraction: .nan` in a YAML file
1407/// would therefore abort the node at load instead of being reported as the configuration error it
1408/// is; values above one, or a negative `loss_margin`, would quietly compute a floor lower than the
1409/// one the cycle needs. Both callers run this first so neither outcome is reachable.
1410///
1411/// Finiteness is tested before every range, and the ranges are two-sided, for the reason spelled out
1412/// on `max_off_front_share_fraction`: every IEEE comparison against `NaN` is false, so a one-sided
1413/// test admits it — and a `NaN` here is not inert. It propagates through the aim point into the
1414/// required rate, and a `NaN` rate compares false against every hysteresis threshold, so the planner
1415/// would hold whatever it last emitted for the life of the Session.
1416pub(crate) fn validate_fill_fractions(fill: &PixFillConfig) -> Result<(), TransportSessionError> {
1417    // Zero is excluded at the low end because it puts the aim point at the instant the cycle's clock
1418    // started — the planner would demand a whole cycle's emission within one sampling interval and
1419    // clamp to `max_rate` forever. One is included: aiming exactly at the hard deadline is a
1420    // defensible, if tight, choice.
1421    if !fill.finish_fraction.is_finite() || !(0.0 < fill.finish_fraction && fill.finish_fraction <= 1.0) {
1422        return Err(TransportSessionError::InvalidConfig(format!(
1423            "fill.finish_fraction ({}) must be a finite fraction in (0.0, 1.0]",
1424            fill.finish_fraction
1425        )));
1426    }
1427    // One is excluded at the top: a margin of 100 % doubles every cycle's emission budget, which is
1428    // not loss tolerance but a second cycle's worth of traffic. Zero is allowed and means "assume a
1429    // lossless return path", which is wrong but bounded — the per-second re-plan catches up.
1430    if !fill.loss_margin.is_finite() || !(0.0..1.0).contains(&fill.loss_margin) {
1431        return Err(TransportSessionError::InvalidConfig(format!(
1432            "fill.loss_margin ({}) must be a finite fraction in [0.0, 1.0)",
1433            fill.loss_margin
1434        )));
1435    }
1436    Ok(())
1437}
1438
1439/// Validates that [`SupervisorConfig`] and [`SsaReconstructorConfig`] are
1440/// mutually consistent.
1441///
1442/// Returns an error if constraints are violated.
1443pub fn validate_pix_supervision(
1444    cfg: &SupervisorConfig,
1445    reconstructor_cfg: &SsaReconstructorConfig,
1446) -> Result<(), TransportSessionError> {
1447    if !(1..=crate::MAX_SSA_BATCH_SIZE).contains(&cfg.ssas_per_request) {
1448        return Err(TransportSessionError::InvalidConfig(format!(
1449            "ssas_per_request ({}) must be between 1 and {}",
1450            cfg.ssas_per_request,
1451            crate::MAX_SSA_BATCH_SIZE
1452        )));
1453    }
1454    // Zero would close the Session on the first lost cycle *and* on the last one standing, which is
1455    // the same thing at the shipping batch size but silently disables batching above it. The
1456    // supervisor clamps to one where it reads this; rejecting here is what an operator sees.
1457    if cfg.max_failed_cycles == 0 {
1458        return Err(TransportSessionError::InvalidConfig(
1459            "max_failed_cycles must be non-zero".into(),
1460        ));
1461    }
1462    if cfg.max_ssa_delivery_time.is_zero() {
1463        return Err(TransportSessionError::InvalidConfig(
1464            "max_ssa_delivery_time must be non-zero".into(),
1465        ));
1466    }
1467    // Both halves are dead config rather than merely odd. A zero interval fires the re-request timer
1468    // on the same instant it is armed, so every arriving commitment message answers itself with an
1469    // ask; an interval at or above the delivery deadline never fires before the deadline closes the
1470    // Session, which is exactly the failure the timer exists to prevent — and neither is visible to
1471    // the operator anywhere but here.
1472    if cfg.commitment_recommit_interval.is_zero() {
1473        return Err(TransportSessionError::InvalidConfig(
1474            "commitment_recommit_interval must be non-zero".into(),
1475        ));
1476    }
1477    if cfg.commitment_recommit_interval >= cfg.max_ssa_delivery_time {
1478        return Err(TransportSessionError::InvalidConfig(format!(
1479            "commitment_recommit_interval ({:?}) must be shorter than max_ssa_delivery_time ({:?}), or a partially \
1480             delivered commitment is never re-requested before the Session closes",
1481            cfg.commitment_recommit_interval, cfg.max_ssa_delivery_time
1482        )));
1483    }
1484    if cfg.max_deposit_wait.is_zero() {
1485        return Err(TransportSessionError::InvalidConfig(
1486            "max_deposit_wait must be non-zero".into(),
1487        ));
1488    }
1489    if cfg.max_recovery_idle.is_zero() {
1490        return Err(TransportSessionError::InvalidConfig(
1491            "max_recovery_idle must be non-zero".into(),
1492        ));
1493    }
1494    if cfg.max_recovery_time.is_zero() {
1495        return Err(TransportSessionError::InvalidConfig(
1496            "max_recovery_time must be non-zero".into(),
1497        ));
1498    }
1499    if cfg.tombstone_retention_window.is_zero() {
1500        return Err(TransportSessionError::InvalidConfig(
1501            "tombstone_retention_window must be non-zero".into(),
1502        ));
1503    }
1504    if cfg.max_served_without_progress == 0 {
1505        return Err(TransportSessionError::InvalidConfig(
1506            "max_served_without_progress must be non-zero".into(),
1507        ));
1508    }
1509    // A fraction outside 0..=1 is unreachable rather than strict — the ratio can never exceed 1 — so a
1510    // typo would silently disable the check instead of tightening it.
1511    if !cfg.max_off_front_share_fraction.is_finite() || !(0.0..=1.0).contains(&cfg.max_off_front_share_fraction) {
1512        return Err(TransportSessionError::InvalidConfig(format!(
1513            "max_off_front_share_fraction ({}) must be a fraction between 0.0 and 1.0",
1514            cfg.max_off_front_share_fraction
1515        )));
1516    }
1517    // Zero would judge the very first off-front share, which mixnet reordering alone can produce.
1518    if cfg.min_share_order_sample == 0 {
1519        return Err(TransportSessionError::InvalidConfig(
1520            "min_share_order_sample must be non-zero".into(),
1521        ));
1522    }
1523    // A zero period is not a fast heartbeat, it is a division by zero in `FillRate::as_packets_per_sec`
1524    // and an assertion inside `RateController::set_rate_per_unit`. Checked whether or not fill is
1525    // enabled, so that turning it on later cannot fail in a way that only shows up at run time.
1526    if cfg.fill.heartbeat.is_zero() {
1527        return Err(TransportSessionError::InvalidConfig(
1528            "fill.heartbeat must be non-zero".into(),
1529        ));
1530    }
1531    // Shared with `validate_incoming_session_pix_config`, which runs *earlier* and does arithmetic
1532    // with both of these — see the function's own documentation.
1533    validate_fill_fractions(&cfg.fill)?;
1534    // Zero would be a disabled filler that still claims to be enabled, which is the one state an
1535    // operator cannot diagnose from the metrics: the rate gauge would sit at the heartbeat and the
1536    // cycles would strand anyway. `enabled` is how fill is turned off.
1537    //
1538    // The upper bound is where `RateController` stops being able to represent the request: its
1539    // `MIN_DELAY` is 100 µs, so anything above 10 000 packets/s is silently clamped, and a silent
1540    // clamp on the one parameter that bounds egress is worse than a refusal. It is also ~83 Mbps of
1541    // keep-alives, which no Session should be asked to produce.
1542    if !(1..=MAX_FILL_RATE).contains(&cfg.fill.max_rate) {
1543        return Err(TransportSessionError::InvalidConfig(format!(
1544            "fill.max_rate ({}) must be between 1 and {MAX_FILL_RATE} packets/s",
1545            cfg.fill.max_rate
1546        )));
1547    }
1548    // Zero would let fill spend the Session's last SURB. That is not merely wasteful: a return packet
1549    // that finds no SURB blocks every packet this node originates for the resolver's
1550    // `surb_resolution_wait`, so the reserve is what keeps the filler from stalling the Session it is
1551    // filling for.
1552    if cfg.fill.min_surb_reserve == 0 {
1553        return Err(TransportSessionError::InvalidConfig(
1554            "fill.min_surb_reserve must be non-zero".into(),
1555        ));
1556    }
1557    if cfg.max_recovery_idle < reconstructor_cfg.max_ack_await_time {
1558        return Err(TransportSessionError::InvalidConfig(
1559            "max_recovery_idle must be >= max_ack_await_time".into(),
1560        ));
1561    }
1562    // An Exit below the floor asks for its next batch before any conforming Entry will admit the
1563    // request. The Entry's gate is computed at the floor precisely because this value does not travel
1564    // on the wire (see `MIN_EARLY_RECOVERY_THRESHOLD`), so the mismatch cannot be detected on the
1565    // link: the one-shot request is dropped, nothing acknowledges the refusal, and the Session dies
1566    // on `max_ssa_delivery_time` waiting for a commitment that was deliberately withheld. Rejecting
1567    // it here turns a silent remote failure into a local startup error, which is the only place the
1568    // operator who set the value can see it.
1569    //
1570    // Written as a range rather than `< floor`, and with the finiteness test in front, for the same
1571    // reason `max_off_front_share_fraction` above is: every IEEE comparison against `NaN` is false, so
1572    // `NaN < floor` admits it, and a `NaN` threshold is not inert — `ceil` leaves it `NaN` and casting
1573    // that to an integer saturates to zero, so the Exit signals early recovery on its first
1574    // reconstructed polynomial. That is the *earliest* possible request against a gate whose entire
1575    // job is to refuse early ones.
1576    if !reconstructor_cfg.early_recovery_threshold.is_finite()
1577        || !(hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD..=1.0)
1578            .contains(&reconstructor_cfg.early_recovery_threshold)
1579    {
1580        return Err(TransportSessionError::InvalidConfig(format!(
1581            "early_recovery_threshold ({}) must be a finite fraction between MIN_EARLY_RECOVERY_THRESHOLD ({}) and \
1582             1.0; a lower value asks for the next SSA batch before a conforming Entry admits the request",
1583            reconstructor_cfg.early_recovery_threshold,
1584            hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD
1585        )));
1586    }
1587    // Documented invariant: tombstone must outlive the ack window.
1588    if cfg.tombstone_retention_window < reconstructor_cfg.max_ack_await_time {
1589        return Err(TransportSessionError::InvalidConfig(
1590            "tombstone_retention_window must be >= max_ack_await_time (otherwise late acks arrive after the tombstone \
1591             expires)"
1592                .into(),
1593        ));
1594    }
1595    // The supervisor must give up on a stalled SSA before the reconstructor reclaims the state it
1596    // would need to finish one. Written against `unused_verifier_lifetime` because that is now the
1597    // only duration governing a live cycle: it is measured from the last acknowledged share
1598    // *anywhere* in the cycle, so a cycle that is still being served never expires and the two
1599    // deadlines only ever race on a cycle that has genuinely stopped.
1600    //
1601    // This replaces a comparison against `incomplete_ssa_lifetime`, which no longer exists. That
1602    // field was inert in any case — it was clamped to the larger of the two lifetimes, so the
1603    // default configuration always resolved it to 1800 s rather than its own 600 s — which is
1604    // exactly the kind of hand-held lifetime pairing that scoping reclamation to the cycle removed.
1605    if cfg.max_recovery_idle >= reconstructor_cfg.unused_verifier_lifetime {
1606        return Err(TransportSessionError::InvalidConfig(
1607            "max_recovery_idle must be < unused_verifier_lifetime".into(),
1608        ));
1609    }
1610    // Reject durations that would overflow the monotonic clock when used as
1611    // deadlines. See `MAX_SUPERVISOR_DURATION`.
1612    //
1613    // The first two are checked *as armed*, i.e. multiplied by the batch size, because that product
1614    // is the duration a deadline is actually set to. Checking the unscaled value would let a config
1615    // pass here and then be silently clamped at arming time, which is the failure mode this cap
1616    // exists to make loud.
1617    //
1618    // The other three are not scaled, so the scaling fact travels per entry rather than being
1619    // asserted for the whole loop. A blanket "as armed for a batch of N" invites the operator to
1620    // divide the reported figure by N and conclude they configured a value they did not — which is
1621    // the same class of misdirection the cap exists to prevent.
1622    for (name, dur, scaled) in [
1623        (
1624            "max_ssa_delivery_time",
1625            scaled_deadline(cfg.max_ssa_delivery_time, cfg.ssas_per_request),
1626            true,
1627        ),
1628        (
1629            "max_deposit_wait",
1630            scaled_deadline(cfg.max_deposit_wait, cfg.ssas_per_request),
1631            true,
1632        ),
1633        ("max_recovery_idle", cfg.max_recovery_idle, false),
1634        ("max_recovery_time", cfg.max_recovery_time, false),
1635        ("tombstone_retention_window", cfg.tombstone_retention_window, false),
1636        ("fill.heartbeat", cfg.fill.heartbeat, false),
1637    ] {
1638        if dur > MAX_SUPERVISOR_DURATION {
1639            let armed = if scaled {
1640                format!(", as armed for a batch of {}", cfg.ssas_per_request)
1641            } else {
1642                String::new()
1643            };
1644            return Err(TransportSessionError::InvalidConfig(format!(
1645                "{name} ({dur:?}{armed}) must not exceed {MAX_SUPERVISOR_DURATION:?}"
1646            )));
1647        }
1648    }
1649    // The two recovery clocks measure different things and only one of them is an anti-drip rule.
1650    // `max_recovery_idle` is service-gated, so it spares a Session that is merely quiet and closes
1651    // one that is consuming service while returning nothing. `max_recovery_time` is neither gated nor
1652    // refreshable — it is a resource backstop for the slot and the reconstructor state, and is
1653    // documented as such throughout this module.
1654    //
1655    // Set at or below the idle deadline, the backstop fires first on *every* cycle. The idle rule —
1656    // the only instrument that can tell a slow honest Session from a cheating one — becomes
1657    // unreachable, and honest Sessions are closed on `RecoveryDeadline` instead. Both values pass
1658    // every check above individually, so this inversion is invisible unless the two are compared.
1659    //
1660    // Checked after the cap loop deliberately. An over-cap `max_recovery_idle` cannot satisfy this
1661    // rule either — no legal `max_recovery_time` exceeds it — so running this first would make the
1662    // cap's own `max_recovery_idle` branch unreachable and swap a precise diagnostic for a vaguer
1663    // one. Representability first, then policy.
1664    if cfg.max_recovery_time <= cfg.max_recovery_idle {
1665        return Err(TransportSessionError::InvalidConfig(format!(
1666            "max_recovery_time ({:?}) must be > max_recovery_idle ({:?}); the hard deadline is a resource backstop, \
1667             and at or below the idle deadline it pre-empts the service-gated idle rule on every cycle",
1668            cfg.max_recovery_time, cfg.max_recovery_idle
1669        )));
1670    }
1671    Ok(())
1672}
1673
1674/// Upper bound on any supervisor duration, as it is actually armed.
1675///
1676/// 24 h is far above anything a supervisor deadline should be, and the point is not the number but
1677/// the failure it forecloses: every phase reads an absent deadline as *no deadline*, and
1678/// `Instant::checked_add` returns `None` for a duration the monotonic clock cannot represent. So a
1679/// large enough configured value does not produce a long deadline, it produces none at all — the
1680/// supervisor silently stops enforcing that phase.
1681///
1682/// Enforced twice, and deliberately so. [`validate_pix_supervision`] *rejects* a config above it,
1683/// which is what an operator should see. `SessionManager::new` *clamps* to it, because nothing in
1684/// this crate calls `validate` and a programmatically assembled config would otherwise reach the
1685/// supervisor unchecked.
1686pub(crate) const MAX_SUPERVISOR_DURATION: Duration = Duration::from_secs(86400);
1687
1688/// Upper bound on [`PixFillConfig::max_rate`], in packets per second.
1689///
1690/// Set where `RateController` stops being able to honour the request rather than at a bandwidth an
1691/// operator would recognise: its `MIN_DELAY` of 100 µs makes 10 000 packets/s the fastest rate it can
1692/// express, and anything above it is clamped without a word. A cap that is *silently* not applied is
1693/// the failure this constant forecloses; that it is also ~83 Mbps of keep-alives is a second reason,
1694/// not the first one.
1695pub(crate) const MAX_FILL_RATE: u32 = 10_000;
1696
1697/// How often the fill planner re-derives the required rate.
1698///
1699/// One second is chosen against the two quantities it has to track. The required rate moves on the
1700/// scale of the recovery deadline (hours), so nothing is lost by sampling it slowly; the *organic*
1701/// rate moves on the scale of an application turning on, and a sampling interval far below the
1702/// [`ORGANIC_WINDOW`] would measure scheduler jitter rather than throughput. It is also the unit the
1703/// planner falls back to when the aim point is already in the past, which is why it must be a
1704/// duration a rate can meaningfully be quoted over.
1705pub(crate) const SAMPLING_INTERVAL: Duration = Duration::from_secs(1);
1706
1707/// Time constant of the exponential average the planner keeps of organic egress.
1708///
1709/// The point of the average is to answer "is the application already covering the need", and to
1710/// answer it without either chasing bursts or lagging a genuine start. Ten seconds is roughly the
1711/// duration over which a Session's throughput is a meaningful number at all — below it a TCP-shaped
1712/// workload is all bursts, above it the planner keeps filling for ten seconds after the application
1713/// has stopped, which is exactly the case fill exists to catch.
1714pub(crate) const ORGANIC_WINDOW: Duration = Duration::from_secs(10);
1715
1716/// A per-cycle deadline duration as it is actually armed for a batch of `ssas_per_request`.
1717///
1718/// One place computes this so the validator and the supervisor cannot disagree about what a
1719/// configuration means. `ssas_per_request` is clamped rather than trusted, because a supervisor can be
1720/// constructed from a config that never went through [`validate_pix_supervision`]; the multiplication
1721/// saturates for the same reason.
1722pub(crate) fn scaled_deadline(per_cycle: Duration, ssas_per_request: usize) -> Duration {
1723    per_cycle.saturating_mul(ssas_per_request.clamp(1, crate::MAX_SSA_BATCH_SIZE) as u32)
1724}
1725
1726// ---------------------------------------------------------------------------
1727// Tests
1728// ---------------------------------------------------------------------------
1729
1730// Deliberately *not* gated on `runtime-tokio`: every test below is a synchronous `#[test]` over the
1731// pure validator, and `default = []`, so a feature gate here removed the whole suite from
1732// `cargo test -p hopr-transport-session`. The gate was invisible because the workspace build
1733// unifies the feature in from other crates — the coverage was only missing for the one command an
1734// operator of this crate would run.
1735/// An arbitrary relayer, for the tests that have to name one in a `SessionPixEvent`.
1736#[cfg(test)]
1737pub(crate) fn test_peer() -> Box<OffchainPublicKey> {
1738    use hopr_api::{OffchainKeypair, types::crypto::prelude::Keypair};
1739
1740    Box::new(*OffchainKeypair::random().public())
1741}
1742
1743#[cfg(test)]
1744mod tests {
1745    use std::time::Duration;
1746
1747    use hopr_protocol_pix::SsaReconstructorConfig;
1748
1749    use super::*;
1750
1751    fn valid_cfg() -> SupervisorConfig {
1752        SupervisorConfig {
1753            ssas_per_request: 1,
1754            allow_dynamic_ssa_batches: true,
1755            max_failed_cycles: 1,
1756            max_ssa_delivery_time: Duration::from_secs(20),
1757            commitment_recommit_interval: Duration::from_secs(3),
1758            max_deposit_wait: Duration::from_secs(60),
1759            max_recovery_idle: Duration::from_secs(60),
1760            max_recovery_time: Duration::from_secs(3600),
1761            max_predeposit_packets: 1024,
1762            max_served_without_progress: 256,
1763            max_off_front_share_fraction: 0.25,
1764            min_share_order_sample: 16384,
1765            tombstone_retention_window: Duration::from_secs(30),
1766            fill: PixFillConfig::default(),
1767        }
1768    }
1769
1770    fn valid_rcn_cfg() -> SsaReconstructorConfig {
1771        SsaReconstructorConfig {
1772            max_ack_await_time: Duration::from_secs(10),
1773            unused_verifier_lifetime: Duration::from_secs(600),
1774            ..Default::default()
1775        }
1776    }
1777
1778    #[test]
1779    fn validation_accepts_valid_configs() {
1780        assert!(validate_pix_supervision(&valid_cfg(), &valid_rcn_cfg()).is_ok());
1781    }
1782
1783    #[test]
1784    fn dynamic_ssa_batches_are_enabled_by_default() {
1785        assert!(SupervisorConfig::default().allow_dynamic_ssa_batches);
1786    }
1787
1788    #[test]
1789    fn validation_rejects_zero_max_ssa_delivery_time() {
1790        let mut cfg = valid_cfg();
1791        cfg.max_ssa_delivery_time = Duration::ZERO;
1792        assert!(validate_pix_supervision(&cfg, &valid_rcn_cfg()).is_err());
1793    }
1794
1795    #[test]
1796    fn validation_rejects_zero_max_deposit_wait() {
1797        let mut cfg = valid_cfg();
1798        cfg.max_deposit_wait = Duration::ZERO;
1799        assert!(validate_pix_supervision(&cfg, &valid_rcn_cfg()).is_err());
1800    }
1801
1802    /// Both ends of the re-request interval are dead config, and invisible anywhere but here.
1803    ///
1804    /// Asserts on the messages: a zero interval also trips nothing else, but an interval at or above
1805    /// the delivery deadline would stay green under `is_err()` if the branch naming it were deleted,
1806    /// since a lone `is_err()` cannot see which guard fired.
1807    #[test]
1808    fn validation_rejects_a_recommit_interval_that_cannot_fire() {
1809        let message_of = |cfg: &SupervisorConfig| match validate_pix_supervision(cfg, &valid_rcn_cfg()) {
1810            Err(TransportSessionError::InvalidConfig(message)) => message,
1811            other => panic!("expected an invalid-config error, got {other:?}"),
1812        };
1813
1814        let mut zero = valid_cfg();
1815        zero.commitment_recommit_interval = Duration::ZERO;
1816        assert!(
1817            message_of(&zero).contains("commitment_recommit_interval must be non-zero"),
1818            "a zero interval fires on the instant it is armed, so every arriving part answers itself"
1819        );
1820
1821        let mut too_long = valid_cfg();
1822        too_long.commitment_recommit_interval = too_long.max_ssa_delivery_time;
1823        assert!(
1824            message_of(&too_long).contains("must be shorter than max_ssa_delivery_time"),
1825            "an interval at the deadline never fires before the Session closes"
1826        );
1827
1828        // And the shipping default satisfies it, which is what makes the rule safe to enforce.
1829        assert!(validate_pix_supervision(&SupervisorConfig::default(), &SsaReconstructorConfig::default()).is_ok());
1830    }
1831
1832    /// Unlike its siblings, this one has to assert on the message.
1833    ///
1834    /// Zero is rejected twice over: by the non-zero check, and by
1835    /// `max_recovery_idle >= max_ack_await_time`, which `ZERO` also fails. `is_err()` cannot see
1836    /// which fired, so with it the test stays green after the branch it names is deleted. The other
1837    /// zero-value tests in this module target fields with no second guard and are sound as written.
1838    #[test]
1839    fn validation_rejects_zero_max_recovery_idle() {
1840        let mut cfg = valid_cfg();
1841        cfg.max_recovery_idle = Duration::ZERO;
1842        let msg = validate_pix_supervision(&cfg, &valid_rcn_cfg())
1843            .expect_err("a zero idle deadline must be rejected")
1844            .to_string();
1845        assert!(msg.contains("max_recovery_idle must be non-zero"), "{msg}");
1846    }
1847
1848    /// Shadowed the same way: `ZERO` also fails `max_recovery_time > max_recovery_idle`.
1849    #[test]
1850    fn validation_rejects_zero_max_recovery_time() {
1851        let mut cfg = valid_cfg();
1852        cfg.max_recovery_time = Duration::ZERO;
1853        let msg = validate_pix_supervision(&cfg, &valid_rcn_cfg())
1854            .expect_err("a zero hard deadline must be rejected")
1855            .to_string();
1856        assert!(msg.contains("max_recovery_time must be non-zero"), "{msg}");
1857    }
1858
1859    #[test]
1860    fn validation_rejects_zero_tombstone_retention_window() {
1861        let mut cfg = valid_cfg();
1862        cfg.tombstone_retention_window = Duration::ZERO;
1863        assert!(validate_pix_supervision(&cfg, &valid_rcn_cfg()).is_err());
1864    }
1865
1866    #[test]
1867    fn validation_rejects_zero_max_served_without_progress() {
1868        let mut cfg = valid_cfg();
1869        cfg.max_served_without_progress = 0;
1870        assert!(validate_pix_supervision(&cfg, &valid_rcn_cfg()).is_err());
1871    }
1872
1873    /// Both ends of the batch range, and the zero that the supervisor would otherwise clamp away.
1874    ///
1875    /// Zero is the one worth spelling out: the supervisor clamps `ssas_per_request` to at least one
1876    /// where it reads it, so a zero here does not fail loudly at runtime, it silently becomes a
1877    /// batch of one. This is the only place an operator is told the value was not honoured.
1878    #[test]
1879    fn validation_rejects_an_out_of_range_batch_size() {
1880        let rcn = valid_rcn_cfg();
1881        for batch in [0, crate::MAX_SSA_BATCH_SIZE + 1] {
1882            let mut cfg = valid_cfg();
1883            cfg.ssas_per_request = batch;
1884            assert!(
1885                validate_pix_supervision(&cfg, &rcn).is_err(),
1886                "ssas_per_request = {batch} is outside 1..={} and must be rejected",
1887                crate::MAX_SSA_BATCH_SIZE
1888            );
1889        }
1890
1891        for batch in [1, crate::MAX_SSA_BATCH_SIZE] {
1892            let mut cfg = valid_cfg();
1893            cfg.ssas_per_request = batch;
1894            assert!(
1895                validate_pix_supervision(&cfg, &rcn).is_ok(),
1896                "ssas_per_request = {batch} is on the boundary and must be accepted"
1897            );
1898        }
1899    }
1900
1901    /// Zero tolerated failures closes the Session on the first lost cycle.
1902    ///
1903    /// Reads as the consistent thing to allow next to the zero-value knobs that *are* legal, and is
1904    /// not: at the shipping batch of one it is indistinguishable from a limit of one, so it would
1905    /// pass unnoticed and then silently disable batching for anyone who raised `ssas_per_request`.
1906    #[test]
1907    fn validation_rejects_zero_max_failed_cycles() {
1908        let mut cfg = valid_cfg();
1909        cfg.max_failed_cycles = 0;
1910        assert!(validate_pix_supervision(&cfg, &valid_rcn_cfg()).is_err());
1911    }
1912
1913    /// The off-front fraction is a ratio, and a value outside `0.0..=1.0` is unreachable rather than
1914    /// strict — so a typo silently disables the check instead of tightening it.
1915    ///
1916    /// `NaN` is the case that needs the finiteness test in front of the range: every IEEE comparison
1917    /// against `NaN` is false, so a bare range test admits it, and an admitted `NaN` makes every
1918    /// later comparison against the fraction false too. That is the check disabled, not relaxed.
1919    #[test]
1920    fn validation_rejects_an_out_of_range_off_front_fraction() {
1921        let rcn = valid_rcn_cfg();
1922        for fraction in [-0.1, 1.1, f64::NAN, f64::INFINITY] {
1923            let mut cfg = valid_cfg();
1924            cfg.max_off_front_share_fraction = fraction;
1925            assert!(
1926                validate_pix_supervision(&cfg, &rcn).is_err(),
1927                "max_off_front_share_fraction = {fraction} must be rejected"
1928            );
1929        }
1930
1931        for fraction in [0.0, 1.0] {
1932            let mut cfg = valid_cfg();
1933            cfg.max_off_front_share_fraction = fraction;
1934            assert!(
1935                validate_pix_supervision(&cfg, &rcn).is_ok(),
1936                "max_off_front_share_fraction = {fraction} is on the boundary and must be accepted"
1937            );
1938        }
1939    }
1940
1941    /// A zero sample would judge the very first off-front share, which mixnet reordering alone
1942    /// produces on an entirely conforming Entry.
1943    #[test]
1944    fn validation_rejects_zero_min_share_order_sample() {
1945        let mut cfg = valid_cfg();
1946        cfg.min_share_order_sample = 0;
1947        assert!(validate_pix_supervision(&cfg, &valid_rcn_cfg()).is_err());
1948    }
1949
1950    /// Zero predeposit packets is strict prepay, and it has to stay a legal configuration.
1951    ///
1952    /// Worth an explicit test because the neighbouring zero-checks make rejecting this one look like
1953    /// the consistent thing to do, and it is not: an Exit that declines to serve anything before the
1954    /// deposit is expressing a policy, not misconfiguring itself. Nothing on the path to funding goes
1955    /// through the egress gate, so the Session still becomes fundable on a zero budget — see
1956    /// [`SupervisorConfig::max_predeposit_packets`]. Without this test, adding a
1957    /// `max_predeposit_packets == 0` rejection alongside the others would silently remove the option.
1958    #[test]
1959    fn validation_accepts_zero_predeposit_packets_for_strict_prepay() {
1960        let mut cfg = valid_cfg();
1961        cfg.max_predeposit_packets = 0;
1962        assert!(
1963            validate_pix_supervision(&cfg, &valid_rcn_cfg()).is_ok(),
1964            "strict prepay (max_predeposit_packets = 0) must remain a supported configuration"
1965        );
1966    }
1967
1968    #[test]
1969    fn validation_rejects_idle_shorter_than_ack_await() {
1970        let mut cfg = valid_cfg();
1971        cfg.max_recovery_idle = Duration::from_secs(5);
1972        let rcn = valid_rcn_cfg();
1973        // max_ack_await_time is 10 s, so 5 < 10 should fail.
1974        assert!(validate_pix_supervision(&cfg, &rcn).is_err());
1975    }
1976
1977    /// The tombstone has to outlive the reconstructor's acknowledgement window, or a late ack arrives
1978    /// with its cycle record already gone.
1979    ///
1980    /// Non-zero but too short is the case that needs stating: `valid_cfg()`'s 30 s window never
1981    /// approaches `valid_rcn_cfg()`'s 10 s, and `validation_rejects_zero_tombstone_retention_window`
1982    /// reaches the zero check earlier in the validator rather than this rule. Without this test the
1983    /// branch could be deleted and the suite would stay green.
1984    #[test]
1985    fn validation_rejects_a_tombstone_shorter_than_the_ack_window() {
1986        let mut cfg = valid_cfg();
1987        let rcn = valid_rcn_cfg();
1988        // max_ack_await_time is 10 s.
1989        cfg.tombstone_retention_window = Duration::from_secs(9);
1990        assert!(validate_pix_supervision(&cfg, &rcn).is_err(), "9 < 10 must reject");
1991        cfg.tombstone_retention_window = Duration::from_secs(10);
1992        assert!(
1993            validate_pix_supervision(&cfg, &rcn).is_ok(),
1994            "equal is admissible — the rule is >=, unlike the verifier-lifetime rule above"
1995        );
1996    }
1997
1998    /// The supervisor must give up on a stalled SSA before the reconstructor reclaims the state
1999    /// needed to finish it, or the session would sit waiting on shares that can never be applied.
2000    #[test]
2001    fn validation_rejects_idle_reaching_the_verifier_lifetime() {
2002        let mut cfg = valid_cfg();
2003        let rcn = valid_rcn_cfg();
2004        // unused_verifier_lifetime is 600 s.
2005        cfg.max_recovery_idle = Duration::from_secs(700);
2006        assert!(validate_pix_supervision(&cfg, &rcn).is_err(), "700 > 600 must reject");
2007        cfg.max_recovery_idle = Duration::from_secs(600);
2008        assert!(
2009            validate_pix_supervision(&cfg, &rcn).is_err(),
2010            "equal must reject too — the supervisor has to act first, not at the same instant"
2011        );
2012        cfg.max_recovery_idle = Duration::from_secs(599);
2013        assert!(validate_pix_supervision(&cfg, &rcn).is_ok(), "599 < 600 must accept");
2014    }
2015
2016    /// Durations are used as `Instant` offsets, so an absurd one would be silently lost to
2017    /// `checked_add` returning `None` rather than producing a deadline. The cap is what makes that
2018    /// unreachable, so it has to reject rather than saturate.
2019    #[test]
2020    fn validation_rejects_durations_beyond_the_deadline_cap() {
2021        let rcn = valid_rcn_cfg();
2022        for mutate in [
2023            (|c: &mut SupervisorConfig| c.max_ssa_delivery_time = Duration::MAX) as fn(&mut SupervisorConfig),
2024            |c: &mut SupervisorConfig| c.max_deposit_wait = Duration::MAX,
2025            |c: &mut SupervisorConfig| c.max_recovery_time = Duration::MAX,
2026            |c: &mut SupervisorConfig| c.tombstone_retention_window = Duration::MAX,
2027        ] {
2028            let mut cfg = valid_cfg();
2029            mutate(&mut cfg);
2030            assert!(
2031                validate_pix_supervision(&cfg, &rcn).is_err(),
2032                "a duration of Duration::MAX must be rejected, not saturated"
2033            );
2034        }
2035    }
2036
2037    /// The cap diagnostic must claim batch scaling only for the durations that are scaled.
2038    ///
2039    /// Only `max_ssa_delivery_time` and `max_deposit_wait` pass through `scaled_deadline`. Reporting
2040    /// "as armed for a batch of N" against the other three invites the operator to divide by N and
2041    /// conclude they configured a value they never set — at `ssas_per_request = 3`, an over-cap
2042    /// `max_recovery_time` would be read as a third of what it is. `is_err()` cannot see the
2043    /// difference, which is why the message itself is asserted here.
2044    #[test]
2045    fn the_deadline_cap_claims_batch_scaling_only_where_it_scales() {
2046        const BATCH: usize = 3;
2047        let rcn = valid_rcn_cfg();
2048
2049        let mut cfg = valid_cfg();
2050        cfg.ssas_per_request = BATCH;
2051        cfg.max_recovery_time = Duration::MAX;
2052        let msg = validate_pix_supervision(&cfg, &rcn)
2053            .expect_err("Duration::MAX must be rejected")
2054            .to_string();
2055        assert!(
2056            msg.contains("max_recovery_time"),
2057            "the unscaled duration must be named: {msg}"
2058        );
2059        assert!(
2060            !msg.contains("as armed for a batch"),
2061            "max_recovery_time is not batch-scaled, so the message must not say it is: {msg}"
2062        );
2063
2064        // The scaled side of the same loop, so the claim is shown to be carried where it is true
2065        // rather than merely dropped everywhere.
2066        let mut cfg = valid_cfg();
2067        cfg.ssas_per_request = BATCH;
2068        cfg.max_ssa_delivery_time = Duration::MAX;
2069        let msg = validate_pix_supervision(&cfg, &rcn)
2070            .expect_err("Duration::MAX must be rejected")
2071            .to_string();
2072        assert!(
2073            msg.contains("max_ssa_delivery_time") && msg.contains("as armed for a batch of 3"),
2074            "a batch-scaled duration must report the scaling: {msg}"
2075        );
2076    }
2077
2078    /// An Exit below the protocol floor cannot have its successor requests admitted by any conforming
2079    /// Entry, so the mismatch has to be caught locally.
2080    ///
2081    /// The Entry's gate is computed at [`hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD`] because
2082    /// this value never goes on the wire — see the constant. That makes a lower local setting a
2083    /// silent, remote failure: the one-shot request goes out early, the Entry drops it, nothing
2084    /// acknowledges the refusal, and the Session dies on its commitment deadline. This is the only
2085    /// place an operator can be told, so both individually valid halves have to be compared here.
2086    #[test]
2087    fn validation_rejects_an_early_recovery_threshold_below_the_protocol_floor() {
2088        let cfg = valid_cfg();
2089        let floor = hopr_protocol_pix::MIN_EARLY_RECOVERY_THRESHOLD;
2090
2091        let below = SsaReconstructorConfig {
2092            early_recovery_threshold: floor - 0.05,
2093            ..valid_rcn_cfg()
2094        };
2095        assert!(
2096            validate_pix_supervision(&cfg, &below).is_err(),
2097            "an Exit asking earlier than any Entry admits must not start"
2098        );
2099
2100        for accepted in [floor, 1.0] {
2101            let rcn = SsaReconstructorConfig {
2102                early_recovery_threshold: accepted,
2103                ..valid_rcn_cfg()
2104            };
2105            assert!(
2106                validate_pix_supervision(&cfg, &rcn).is_ok(),
2107                "{accepted} is at or above the floor and must be accepted"
2108            );
2109        }
2110
2111        assert!(
2112            SsaReconstructorConfig::default().early_recovery_threshold >= floor,
2113            "the shipped default must itself be admissible"
2114        );
2115    }
2116
2117    /// `NaN` is unordered, so a bare `< floor` check does not reject it. The reconstructor's
2118    /// derived range validator has the same problem: both `NaN < 0.0` and `NaN > 1.0` are false.
2119    /// Once admitted, casting `ceil(NaN)` to `usize` produces zero and the early notification fires
2120    /// on the first recovered polynomial, well before the Entry's successor gate opens.
2121    #[test]
2122    fn validation_rejects_a_non_finite_early_recovery_threshold() {
2123        let cfg = valid_cfg();
2124        let rcn = SsaReconstructorConfig {
2125            early_recovery_threshold: f64::NAN,
2126            ..valid_rcn_cfg()
2127        };
2128
2129        assert!(
2130            validate_pix_supervision(&cfg, &rcn).is_err(),
2131            "a non-finite threshold must not bypass the protocol floor"
2132        );
2133    }
2134
2135    /// The hard recovery deadline is a backstop, so it has to sit outside the rule that should bind.
2136    ///
2137    /// `max_recovery_idle` is service-gated and `max_recovery_time` is not, so inverting them does not
2138    /// merely reorder two timeouts: the ungated deadline fires on every cycle and the gated one — the
2139    /// only rule that distinguishes a quiet Session from a cheating one — is never consulted. Both
2140    /// values are individually legal at every boundary tested elsewhere in this module, so nothing
2141    /// short of comparing them catches it.
2142    #[test]
2143    fn validation_rejects_a_recovery_deadline_that_pre_empts_the_idle_rule() {
2144        let mut cfg = valid_cfg();
2145        let rcn = valid_rcn_cfg();
2146        let idle = cfg.max_recovery_idle;
2147
2148        cfg.max_recovery_time = idle;
2149        assert!(
2150            validate_pix_supervision(&cfg, &rcn).is_err(),
2151            "equal must reject: a backstop that expires with the rule it backs up has replaced it"
2152        );
2153
2154        cfg.max_recovery_time = idle - Duration::from_secs(1);
2155        assert!(validate_pix_supervision(&cfg, &rcn).is_err(), "below must reject");
2156
2157        cfg.max_recovery_time = idle + Duration::from_secs(1);
2158        assert!(
2159            validate_pix_supervision(&cfg, &rcn).is_ok(),
2160            "strictly above is the whole requirement; how far above is an operator's call"
2161        );
2162
2163        let shipped = SupervisorConfig::default();
2164        assert!(
2165            shipped.max_recovery_time > shipped.max_recovery_idle,
2166            "the shipped defaults must themselves satisfy the rule"
2167        );
2168    }
2169
2170    /// `max_recovery_idle` is bounded from both directions, so its cap check must be reachable
2171    /// without first tripping the verifier-lifetime rule above.
2172    ///
2173    /// The recovery-clock pairing rule is checked after the duration cap for this reason: an idle
2174    /// deadline above the cap admits no legal `max_recovery_time` greater than it, so running the
2175    /// pairing first would make this branch unreachable.
2176    #[test]
2177    fn validation_rejects_an_over_cap_idle_against_a_long_verifier_lifetime() {
2178        let mut cfg = valid_cfg();
2179        cfg.max_recovery_idle = Duration::from_secs(86_401);
2180        let rcn = SsaReconstructorConfig {
2181            unused_verifier_lifetime: Duration::from_secs(200_000),
2182            ..valid_rcn_cfg()
2183        };
2184        assert!(validate_pix_supervision(&cfg, &rcn).is_err());
2185    }
2186
2187    /// The `Display` value of every close reason is a metric label, so it is API.
2188    ///
2189    /// [`crate::telemetry::pix::record_pix_closure`] derives the `reason` label of
2190    /// `hopr_pix_closures_total` from `to_string()`. A variant rename, or a `strum`
2191    /// attribute added to one, silently renames a metric series and breaks whatever dashboards and
2192    /// alerts were built on the old name.
2193    ///
2194    /// The `Display` string is snapshotted rather than the `Debug` form used by the sibling guard in
2195    /// [`crate::types`], because `Display` is what actually reaches the label: `#[strum(serialize =
2196    /// "…")]` makes the two diverge, and only one of them is observable to an operator.
2197    ///
2198    /// The array is hand-maintained, so the wildcard-free match below is what keeps it honest — a new
2199    /// variant fails to compile *here*, next to the array it has to be added to, rather than
2200    /// quietly falling outside a snapshot that still reads as a guarantee.
2201    #[test]
2202    fn pix_close_reason_display_values_are_stable() {
2203        let reasons = [
2204            SessionPixCloseReason::CommitmentTimeout,
2205            SessionPixCloseReason::DepositTimeout,
2206            SessionPixCloseReason::DepositObserverClosed,
2207            SessionPixCloseReason::RecoveryIdle,
2208            SessionPixCloseReason::RecoveryDeadline,
2209            SessionPixCloseReason::UnverifiableShares,
2210            SessionPixCloseReason::BatchServedOutOfOrder,
2211            SessionPixCloseReason::CounterRegression,
2212            SessionPixCloseReason::InvalidTransition,
2213            SessionPixCloseReason::NoSsaRemaining,
2214            SessionPixCloseReason::SupervisorUnavailable,
2215            SessionPixCloseReason::SessionClosed,
2216            SessionPixCloseReason::Drained,
2217        ];
2218
2219        for reason in reasons {
2220            match reason {
2221                SessionPixCloseReason::CommitmentTimeout
2222                | SessionPixCloseReason::DepositTimeout
2223                | SessionPixCloseReason::DepositObserverClosed
2224                | SessionPixCloseReason::RecoveryIdle
2225                | SessionPixCloseReason::RecoveryDeadline
2226                | SessionPixCloseReason::UnverifiableShares
2227                | SessionPixCloseReason::BatchServedOutOfOrder
2228                | SessionPixCloseReason::CounterRegression
2229                | SessionPixCloseReason::InvalidTransition
2230                | SessionPixCloseReason::NoSsaRemaining
2231                | SessionPixCloseReason::SupervisorUnavailable
2232                | SessionPixCloseReason::SessionClosed
2233                | SessionPixCloseReason::Drained => {}
2234            }
2235        }
2236
2237        let labels = reasons.map(|reason| reason.to_string());
2238        insta::assert_debug_snapshot!(labels);
2239    }
2240
2241    /// One unverifiable-share report must close the Session, and no configuration may change that.
2242    ///
2243    /// Pinned end-to-end through the public [`SupervisorConfig`] rather than the state machine's own
2244    /// internals, because that is the surface a tolerance would come back on. The two limits this
2245    /// replaces were shipped at zero and read `total > limit`, so re-introducing either — or any other
2246    /// knob that makes the close conditional — fails here rather than silently restoring tolerance for
2247    /// a failure that has already doomed the cycle.
2248    #[test]
2249    fn one_unverifiable_share_report_closes_the_session() -> anyhow::Result<()> {
2250        use hopr_api::types::crypto_random::Randomizable;
2251
2252        use crate::supervision::supervisor::SessionPixSupervisor;
2253
2254        let pseudonym = HoprPseudonym::random();
2255        let ssa_id = SsaId::new(pseudonym, hopr_protocol_pix::SsaIndex::MIN);
2256        let dims = PixParams::try_new(10, 5, 7, crate::types::LOCAL_PIX_SUITE)?;
2257        let now = std::time::Instant::now();
2258
2259        let (mut supervisor, _) = SessionPixSupervisor::new(SupervisorConfig::default(), dims, pseudonym, now);
2260        supervisor.handle_event(&SessionPixEvent::SsaRequestSent(ssa_id), now, 0);
2261
2262        let actions = supervisor.handle_event(
2263            &SessionPixEvent::UnverifiableShares {
2264                ssa_id,
2265                observed_total: 1,
2266                peer: crate::supervision::test_peer(),
2267            },
2268            now,
2269            0,
2270        );
2271
2272        assert!(
2273            actions
2274                .iter()
2275                .any(|a| matches!(a, SessionPixAction::Close(SessionPixCloseReason::UnverifiableShares))),
2276            "one unverifiable share must close the session under the default config, got {actions:?}"
2277        );
2278
2279        // And under a config whose every other tolerance is at its most permissive, so the close
2280        // cannot be attributed to something else being strict.
2281        let permissive = SupervisorConfig {
2282            max_failed_cycles: usize::MAX,
2283            max_off_front_share_fraction: 1.0,
2284            min_share_order_sample: u64::MAX,
2285            max_predeposit_packets: u64::MAX,
2286            max_served_without_progress: u64::MAX,
2287            ..SupervisorConfig::default()
2288        };
2289        let (mut supervisor, _) = SessionPixSupervisor::new(permissive, dims, pseudonym, now);
2290        supervisor.handle_event(&SessionPixEvent::SsaRequestSent(ssa_id), now, 0);
2291
2292        let actions = supervisor.handle_event(
2293            &SessionPixEvent::UnverifiableShares {
2294                ssa_id,
2295                observed_total: 1,
2296                peer: crate::supervision::test_peer(),
2297            },
2298            now,
2299            0,
2300        );
2301
2302        assert!(
2303            actions
2304                .iter()
2305                .any(|a| matches!(a, SessionPixAction::Close(SessionPixCloseReason::UnverifiableShares))),
2306            "no configuration may tolerate an unverifiable share, got {actions:?}"
2307        );
2308
2309        Ok(())
2310    }
2311
2312    // ---------------------------------------------------------------
2313    // PIX fill configuration
2314    // ---------------------------------------------------------------
2315
2316    /// The shipped fill defaults must validate against the shipped reconstructor.
2317    ///
2318    /// Fill is on by default, so a default configuration that could not pass its own validator would
2319    /// not be a misconfiguration an operator had chosen — it would be a node that refuses to start.
2320    #[test]
2321    fn validation_accepts_the_shipped_fill_defaults() {
2322        let cfg = SupervisorConfig::default();
2323        assert!(cfg.fill.enabled, "fill ships on");
2324        validate_pix_supervision(&cfg, &SsaReconstructorConfig::default()).expect("the shipped defaults must validate");
2325    }
2326
2327    /// Zero is not "fill as slowly as possible", and neither is anything the rate controller cannot
2328    /// express.
2329    ///
2330    /// A zero cap is a filler that is on but sends nothing, which is the one broken state an operator
2331    /// cannot read off the metrics: the rate gauge sits at the heartbeat and the cycles strand anyway.
2332    /// Above `MAX_FILL_RATE` the controller's `MIN_DELAY` silently clamps instead, and a silent clamp
2333    /// on the only bound on this node's self-generated egress is worse than a refusal.
2334    #[test]
2335    fn validation_rejects_a_fill_rate_ceiling_outside_what_the_controller_can_express() {
2336        for max_rate in [0, MAX_FILL_RATE + 1] {
2337            let cfg = SupervisorConfig {
2338                fill: PixFillConfig {
2339                    max_rate,
2340                    ..PixFillConfig::default()
2341                },
2342                ..valid_cfg()
2343            };
2344            let msg = validate_pix_supervision(&cfg, &valid_rcn_cfg())
2345                .expect_err("a ceiling outside the representable range must be rejected")
2346                .to_string();
2347            assert!(msg.contains("fill.max_rate"), "{msg}");
2348        }
2349
2350        for max_rate in [1, MAX_FILL_RATE] {
2351            let cfg = SupervisorConfig {
2352                fill: PixFillConfig {
2353                    max_rate,
2354                    ..PixFillConfig::default()
2355                },
2356                ..valid_cfg()
2357            };
2358            validate_pix_supervision(&cfg, &valid_rcn_cfg())
2359                .unwrap_or_else(|error| panic!("{max_rate} is inside the range and must be accepted: {error}"));
2360        }
2361    }
2362
2363    /// A `NaN` fraction is not inert, which is why finiteness is tested before the range.
2364    ///
2365    /// Every IEEE comparison against `NaN` is false, so a one-sided range check *admits* it. It then
2366    /// propagates: a `NaN` aim point makes the required rate `NaN`, and a `NaN` rate compares false
2367    /// against every hysteresis threshold — so the planner would hold whatever it last emitted for the
2368    /// life of the Session, which for a Session that never emitted anything is silence.
2369    #[test]
2370    fn validation_rejects_non_finite_fill_fractions() {
2371        for (name, fill) in [
2372            (
2373                "fill.finish_fraction",
2374                PixFillConfig {
2375                    finish_fraction: f64::NAN,
2376                    ..PixFillConfig::default()
2377                },
2378            ),
2379            (
2380                "fill.loss_margin",
2381                PixFillConfig {
2382                    loss_margin: f64::NAN,
2383                    ..PixFillConfig::default()
2384                },
2385            ),
2386        ] {
2387            let cfg = SupervisorConfig { fill, ..valid_cfg() };
2388            let msg = validate_pix_supervision(&cfg, &valid_rcn_cfg())
2389                .expect_err("a NaN fraction must be rejected")
2390                .to_string();
2391            assert!(msg.contains(name), "{msg}");
2392        }
2393    }
2394
2395    /// The two fractions have deliberately different open ends, and both must be enforced.
2396    ///
2397    /// A `finish_fraction` of zero puts the aim point at the instant the cycle's clock started, so the
2398    /// planner demands a whole cycle's emission inside one sampling interval and pins itself to
2399    /// `max_rate` forever. A `loss_margin` of one doubles every cycle's emission budget, which is not
2400    /// loss tolerance but a second cycle's worth of traffic.
2401    #[test]
2402    fn validation_rejects_fill_fractions_outside_their_ranges() {
2403        let zero_finish = SupervisorConfig {
2404            fill: PixFillConfig {
2405                finish_fraction: 0.0,
2406                ..PixFillConfig::default()
2407            },
2408            ..valid_cfg()
2409        };
2410        assert!(validate_pix_supervision(&zero_finish, &valid_rcn_cfg()).is_err());
2411
2412        let whole_finish = SupervisorConfig {
2413            fill: PixFillConfig {
2414                finish_fraction: 1.0,
2415                ..PixFillConfig::default()
2416            },
2417            ..valid_cfg()
2418        };
2419        validate_pix_supervision(&whole_finish, &valid_rcn_cfg())
2420            .expect("aiming exactly at the hard deadline is tight but legal");
2421
2422        let whole_margin = SupervisorConfig {
2423            fill: PixFillConfig {
2424                loss_margin: 1.0,
2425                ..PixFillConfig::default()
2426            },
2427            ..valid_cfg()
2428        };
2429        assert!(validate_pix_supervision(&whole_margin, &valid_rcn_cfg()).is_err());
2430
2431        // And the open end that is *included*: zero means "assume a lossless return path", which is
2432        // wrong but bounded — the per-second re-plan catches up. Asserted because the two fractions
2433        // have deliberately opposite open ends, and a range copied from one to the other would pass
2434        // every rejection above while quietly refusing a legal configuration.
2435        let no_margin = SupervisorConfig {
2436            fill: PixFillConfig {
2437                loss_margin: 0.0,
2438                ..PixFillConfig::default()
2439            },
2440            ..valid_cfg()
2441        };
2442        validate_pix_supervision(&no_margin, &valid_rcn_cfg())
2443            .expect("a zero loss margin is a lossless-path assumption, not an invalid one");
2444    }
2445
2446    /// A zero heartbeat and a zero SURB reserve are both rejected, and neither for style.
2447    ///
2448    /// A zero period divides by zero in `FillRate::as_packets_per_sec` and trips an assertion inside
2449    /// `RateController::set_rate_per_unit`. A zero reserve lets fill spend the Session's last SURB,
2450    /// and a return packet that finds no SURB holds every packet this node originates for the
2451    /// resolver's whole `surb_resolution_wait` — so the filler would stall the Session it is filling
2452    /// for.
2453    #[test]
2454    fn validation_rejects_a_zero_heartbeat_or_surb_reserve() {
2455        let no_heartbeat = SupervisorConfig {
2456            fill: PixFillConfig {
2457                heartbeat: Duration::ZERO,
2458                ..PixFillConfig::default()
2459            },
2460            ..valid_cfg()
2461        };
2462        let msg = validate_pix_supervision(&no_heartbeat, &valid_rcn_cfg())
2463            .expect_err("a zero heartbeat must be rejected")
2464            .to_string();
2465        assert!(msg.contains("fill.heartbeat must be non-zero"), "{msg}");
2466
2467        let no_reserve = SupervisorConfig {
2468            fill: PixFillConfig {
2469                min_surb_reserve: 0,
2470                ..PixFillConfig::default()
2471            },
2472            ..valid_cfg()
2473        };
2474        let msg = validate_pix_supervision(&no_reserve, &valid_rcn_cfg())
2475            .expect_err("a zero SURB reserve must be rejected")
2476            .to_string();
2477        assert!(msg.contains("fill.min_surb_reserve must be non-zero"), "{msg}");
2478    }
2479
2480    /// The heartbeat is armed like every other deadline, so it takes the same 24-hour cap.
2481    #[test]
2482    fn validation_rejects_a_heartbeat_over_the_duration_cap() {
2483        let cfg = SupervisorConfig {
2484            fill: PixFillConfig {
2485                heartbeat: MAX_SUPERVISOR_DURATION + Duration::from_secs(1),
2486                ..PixFillConfig::default()
2487            },
2488            ..valid_cfg()
2489        };
2490        let msg = validate_pix_supervision(&cfg, &valid_rcn_cfg())
2491            .expect_err("a heartbeat above the cap must be rejected")
2492            .to_string();
2493        assert!(msg.contains("fill.heartbeat"), "{msg}");
2494    }
2495}