Skip to main content

hopr_transport_session/supervision/
gate.rs

1//! [`ServiceGate`] — bounded predeposit egress gate for PIX sessions.
2//!
3//! While the current front cycle is unfunded, the gate enforces a provisional packet budget from
4//! Exit to Entry. Once that cycle is funded, it enforces a ceiling on packets served without its
5//! recovery progress as a defense-in-depth backstop. A paid handoff restores the allowance for an
6//! unfunded successor.
7//! On poisoning, all acquires fail permanently.
8
9use std::sync::{
10    Arc,
11    atomic::{AtomicBool, AtomicU64, Ordering},
12};
13
14use crate::utils::SlotNotify;
15
16/// Error returned when the gate is poisoned.
17#[derive(Debug, Clone, thiserror::Error)]
18#[error("service gate is poisoned (session closed)")]
19pub struct GateClosed;
20
21/// Why the gate refused a packet.
22///
23/// A closed enum, so it can be a metric label — the same argument as
24/// `SessionPixCloseReason`, and it is carried for the same reason:
25/// an operator asked "why is egress stalled" needs to tell an Entry that has not deposited from one
26/// that has deposited but stopped returning shares. Those are different faults with different
27/// remedies, and a bare "refused" conflates them.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display)]
29#[strum(serialize_all = "snake_case")]
30pub enum GateBlockReason {
31    /// Unfunded, and the current front has spent its whole predeposit allowance. Service resumes
32    /// when the deposit confirms, or when a paid handoff restores the allowance for a successor.
33    PredepositExhausted,
34    /// Funded, but service has run `max_served_without_progress` packets ahead of the shares coming
35    /// back. Service resumes on the next validated progress notification for the front cycle.
36    ShareLag,
37}
38
39/// What the gate answered a packet with.
40///
41/// Replaces the `bool` this used to be. Both refusal sites already knew which case they were —
42/// carrying it costs nothing, allocates nothing, and is what lets the egress path report *why* it
43/// parked rather than only that it did.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum GateVerdict {
46    /// A permit was taken; the packet may go.
47    Admitted,
48    /// No permit is available right now, for this reason.
49    Blocked(GateBlockReason),
50}
51
52/// Bounded predeposit service gate for a single PIX session.
53///
54/// # Parking
55///
56/// While the front is unfunded, exhausting its predeposit budget parks [`acquire`](Self::acquire).
57/// Once funded, exceeding the served-without-progress ceiling parks on the same mechanism. On
58/// [`release_service`](Self::release_service), [`withhold_service`](Self::withhold_service),
59/// [`notify_progress`](Self::notify_progress), or [`poison`](Self::poison), all parked callers are
60/// woken.
61pub struct ServiceGate {
62    /// Monotonic number of packets served.
63    served: AtomicU64,
64    /// Of those, the ones taken from a predeposit allowance rather than from funded service.
65    ///
66    /// Monotonic over the life of the gate and never reset by a rotation, so it is a share of
67    /// [`served`](Self::served) rather than a per-front figure — see
68    /// [`served_split`](Self::served_split), which is the only reader.
69    served_predeposit: AtomicU64,
70    /// Predeposit budget restored after each paid front-cycle handoff.
71    predeposit_budget: u64,
72    /// Remaining predeposit budget (tracked separately so we can park on 0).
73    remaining: AtomicU64,
74    /// Whether the current front cycle is funded.
75    funded: AtomicBool,
76    /// Incremented after every mode publication.
77    ///
78    /// Permit acquisition performs an RMW on this epoch before committing its counter update. That
79    /// gives mode transitions and permits one atomic ordering point: a permit either belongs to the
80    /// old front or observes the new mode, rather than loading `funded` before a transition and
81    /// committing after it.
82    mode_epoch: AtomicU64,
83    /// Whether the gate is poisoned.
84    poisoned: AtomicBool,
85    /// Waker for parked writers.
86    notify: SlotNotify,
87    /// Ceiling on packets served since last progress notification.
88    ceiling: AtomicU64,
89    /// Snapshot of `served` at last progress notification.
90    served_at_last_progress: AtomicU64,
91}
92
93impl ServiceGate {
94    /// Create a new gate with the given predeposit budget and progress ceiling.
95    pub fn new(predeposit_budget: u64, max_served_without_progress: u64) -> Arc<Self> {
96        Arc::new(Self {
97            served: AtomicU64::new(0),
98            served_predeposit: AtomicU64::new(0),
99            predeposit_budget,
100            remaining: AtomicU64::new(predeposit_budget),
101            funded: AtomicBool::new(false),
102            mode_epoch: AtomicU64::new(0),
103            poisoned: AtomicBool::new(false),
104            notify: SlotNotify::new(),
105            ceiling: AtomicU64::new(max_served_without_progress),
106            served_at_last_progress: AtomicU64::new(0),
107        })
108    }
109
110    /// Acquire a service permit.
111    ///
112    /// After funding, enforces a ceiling on packets served without SSA recovery
113    /// progress (see the `ceiling` field). Parks on `SlotNotify` when the ceiling or predeposit
114    /// budget is exceeded.
115    pub async fn acquire(self: &Arc<Self>) -> Result<(), GateClosed> {
116        if self.poisoned.load(Ordering::Acquire) {
117            return Err(GateClosed);
118        }
119
120        loop {
121            if self.poisoned.load(Ordering::Acquire) {
122                return Err(GateClosed);
123            }
124
125            let mode_epoch = self.mode_epoch.load(Ordering::Acquire);
126            if self.funded.load(Ordering::Acquire) {
127                // Funded path: ceiling-checking CAS loop.
128                let served = self.served.load(Ordering::Acquire);
129                let base = self.served_at_last_progress.load(Ordering::Acquire);
130                if served.saturating_sub(base) >= self.ceiling.load(Ordering::Acquire) {
131                    // Ceiling exceeded — park.
132                    let notified = self.notify.notified();
133
134                    // Double-check after registering interest.
135                    if self.poisoned.load(Ordering::Acquire) {
136                        return Err(GateClosed);
137                    }
138                    if self.mode_epoch.load(Ordering::Acquire) != mode_epoch || !self.funded.load(Ordering::Acquire) {
139                        continue;
140                    }
141                    let served2 = self.served.load(Ordering::Acquire);
142                    let base2 = self.served_at_last_progress.load(Ordering::Acquire);
143                    if served2.saturating_sub(base2) < self.ceiling.load(Ordering::Acquire) {
144                        // Progress happened while registering — retry.
145                        continue;
146                    }
147
148                    notified.await;
149                    continue;
150                }
151
152                // Re-check poison right before CAS so that a concurrent
153                // poison() is not missed between the entry check and here.
154                if self.poisoned.load(Ordering::Acquire) {
155                    return Err(GateClosed);
156                }
157
158                if !self.mode_is_current(mode_epoch) {
159                    continue;
160                }
161
162                if self
163                    .served
164                    .compare_exchange(served, served + 1, Ordering::AcqRel, Ordering::Relaxed)
165                    .is_ok()
166                {
167                    return Ok(());
168                }
169                // CAS failed — retry.
170                continue;
171            }
172
173            // Not yet funded — try predeposit budget.
174            let remaining = self.remaining.load(Ordering::Acquire);
175
176            if remaining > 0 {
177                // Re-check poison right before CAS so that a concurrent
178                // poison() is not missed between the entry check and here.
179                if self.poisoned.load(Ordering::Acquire) {
180                    return Err(GateClosed);
181                }
182                if !self.mode_is_current(mode_epoch) {
183                    continue;
184                }
185                if self
186                    .remaining
187                    .compare_exchange(remaining, remaining - 1, Ordering::AcqRel, Ordering::Relaxed)
188                    .is_ok()
189                {
190                    // Counted here as well as on the synchronous path: a writer that parked on an
191                    // exhausted allowance and was woken by a paid handoff takes its permit through
192                    // *this* branch, and a split that missed those would attribute predeposit
193                    // service to the funded bucket.
194                    //
195                    // Published before `served`, and `served` published with `Release`, so that
196                    // `served_split`'s acquire load of `served` cannot see this permit in the total
197                    // while still reading the old predeposit count — see its documentation.
198                    self.served_predeposit.fetch_add(1, Ordering::Relaxed);
199                    self.served.fetch_add(1, Ordering::Release);
200                    return Ok(());
201                }
202                // CAS failed — retry.
203                continue;
204            }
205
206            // Budget exhausted — park.
207            //
208            // Register interest FIRST, then re-check conditions. This
209            // prevents a missed wake-up: without the double-check, a
210            // concurrent release_service()/poison() can call
211            // notify_waiters() between the budget check above and the
212            // Notified creation below, and the new Notified would never
213            // observe that notification.
214            let notified = self.notify.notified();
215
216            // Double-check: after registering, re-read all conditions
217            // that could have changed since the last load above.
218            if self.poisoned.load(Ordering::Acquire) {
219                return Err(GateClosed);
220            }
221            if self.mode_epoch.load(Ordering::Acquire) != mode_epoch || self.funded.load(Ordering::Acquire) {
222                // Re-enter the loop — the funded path handles ceiling checks.
223                continue;
224            }
225            if self.remaining.load(Ordering::Acquire) > 0 {
226                continue;
227            }
228
229            // Budget exhausted — park and wait for wake-up.
230            notified.await;
231        }
232    }
233
234    /// Current value of the served counter.
235    pub fn served_total(&self) -> u64 {
236        self.served.load(Ordering::Acquire)
237    }
238
239    /// Packets served against the current front's predeposit allowance and not yet paid for.
240    ///
241    /// Zero while funded, and that is the definition rather than an approximation: funding is
242    /// exactly the event that converts the allowance the current front spent into service the Entry
243    /// has paid for, so there is nothing outstanding to report. A later
244    /// [`withhold_service`](Self::withhold_service) restores the whole allowance for an unfunded
245    /// successor, and the figure starts climbing again from zero for that cycle.
246    ///
247    /// Read once per supervisor turn rather than per packet, so the two relaxed loads are not on any
248    /// hot path.
249    pub fn predeposit_exposure(&self) -> u64 {
250        if self.funded.load(Ordering::Acquire) {
251            return 0;
252        }
253        self.predeposit_budget
254            .saturating_sub(self.remaining.load(Ordering::Acquire))
255    }
256
257    #[cfg(test)]
258    pub fn funded(&self) -> bool {
259        self.funded.load(Ordering::Acquire)
260    }
261
262    /// Linearization point shared by a permit and mode publication.
263    fn mode_is_current(&self, epoch: u64) -> bool {
264        self.mode_epoch
265            .compare_exchange(epoch, epoch, Ordering::AcqRel, Ordering::Acquire)
266            .is_ok()
267    }
268
269    /// Publish funded mode for the current front and wake all parked writers.
270    ///
271    /// `acquire` then enforces the served-without-progress ceiling instead of the predeposit budget.
272    /// Calling this while already funded represents a funded-to-funded front handoff and starts the
273    /// new front with a fresh ceiling window.
274    ///
275    /// Snapshots `served_total` into `served_at_last_progress` so the ceiling
276    /// check starts from the moment of funding and does not count predeposit
277    /// packets against the post-funding budget.
278    pub fn release_service(self: &Arc<Self>) {
279        // Snapshot the served counter at the moment of funding so the ceiling
280        // check does not count predeposit traffic against the post-funding
281        // max_served_without_progress budget.
282        self.served_at_last_progress
283            .store(self.served.load(Ordering::Acquire), Ordering::Release);
284        // Published *after* the watermark, so a caller that observes `funded` observes the snapshot
285        // too — the release store orders everything sequenced before it. The other way round leaves
286        // a window in which the funded branch judges the whole predeposit-era `served` count against
287        // the ceiling and refuses service that is in fact available. The window is self-clearing,
288        // since the wake below releases whoever parked in it, but it costs a spurious refusal on
289        // every funding event for nothing.
290        self.funded.store(true, Ordering::Release);
291        self.mode_epoch.fetch_add(1, Ordering::Release);
292        // Wake all parkers — predeposit-parked writers re-enter and take the
293        // funded path, which checks the ceiling.
294        self.notify.notify_waiters();
295    }
296
297    /// Return to predeposit mode for the next unfunded front cycle.
298    ///
299    /// The allowance is restored before the mode is published, so a permit that observes the new
300    /// epoch also observes the complete budget. Parked ceiling waiters are woken to re-evaluate the
301    /// predeposit branch; with a zero allowance they correctly park again.
302    pub fn withhold_service(self: &Arc<Self>) {
303        self.remaining.store(self.predeposit_budget, Ordering::Release);
304        self.funded.store(false, Ordering::Release);
305        self.mode_epoch.fetch_add(1, Ordering::Release);
306        self.notify.notify_waiters();
307    }
308
309    /// Record SSA recovery progress: snapshots the served counter so the
310    /// ceiling reopens, and wakes any writers parked on the ceiling.
311    pub fn notify_progress(self: &Arc<Self>) {
312        self.served_at_last_progress
313            .store(self.served.load(Ordering::Acquire), Ordering::Release);
314        self.notify.notify_waiters();
315    }
316
317    /// Poison the gate: prevent all further acquires.
318    ///
319    /// Parked and future callers receive [`GateClosed`].
320    ///
321    /// # Semantics
322    ///
323    /// After `poison()` returns, at most one in-flight `acquire()` per
324    /// concurrent caller may still observe the gate as not poisoned (a
325    /// parked awaiter that wakes before the poison store is visible).
326    pub fn poison(&self) {
327        self.poisoned.store(true, Ordering::Release);
328        self.notify.notify_waiters();
329    }
330
331    /// Non-blocking try-acquire — the egress fast path.
332    ///
333    /// Returns [`GateVerdict::Admitted`] on success, [`GateVerdict::Blocked`] with the reason if the
334    /// predeposit budget is exhausted (and the gate not yet funded) or the ceiling is exceeded (gate
335    /// funded), or [`GateClosed`] if poisoned.
336    ///
337    /// `Blocked` means *the gate refused*, and only ever that: both branches retry a lost
338    /// compare-exchange rather than reporting it. Contention on `served` is not a refusal — service
339    /// was available and the caller would be turned away anyway — and with several concurrent egress
340    /// writers, reporting it as one converts contention into spurious refusals on the documented
341    /// fast path.
342    ///
343    /// Every outgoing data packet of a supervised Session comes through here, and service is
344    /// available for all but a vanishing fraction of them, so this answering synchronously is what
345    /// keeps gating off the allocator: only [`acquire`](Self::acquire)'s parking path needs a future
346    /// large enough to box, and that path is about to block anyway. The same argument is why the
347    /// funded branch below does not count anything of its own: it is the steady state, at the
348    /// Session's full packet rate, and the split it would produce is available by subtraction.
349    pub fn try_acquire_sync(&self) -> Result<GateVerdict, GateClosed> {
350        loop {
351            if self.poisoned.load(Ordering::Acquire) {
352                return Err(GateClosed);
353            }
354
355            let mode_epoch = self.mode_epoch.load(Ordering::Acquire);
356            if self.funded.load(Ordering::Acquire) {
357                let served = self.served.load(Ordering::Acquire);
358                let base = self.served_at_last_progress.load(Ordering::Acquire);
359                if served.saturating_sub(base) >= self.ceiling.load(Ordering::Acquire) {
360                    if !self.mode_is_current(mode_epoch) {
361                        continue;
362                    }
363                    return Ok(GateVerdict::Blocked(GateBlockReason::ShareLag));
364                }
365                if self.poisoned.load(Ordering::Acquire) {
366                    return Err(GateClosed);
367                }
368                if !self.mode_is_current(mode_epoch) {
369                    continue;
370                }
371                if self
372                    .served
373                    .compare_exchange(served, served + 1, Ordering::AcqRel, Ordering::Relaxed)
374                    .is_ok()
375                {
376                    return Ok(GateVerdict::Admitted);
377                }
378                continue;
379            }
380
381            // Try to consume from the current front's predeposit budget.
382            let remaining = self.remaining.load(Ordering::Acquire);
383            if remaining == 0 {
384                if !self.mode_is_current(mode_epoch) {
385                    continue;
386                }
387                return Ok(GateVerdict::Blocked(GateBlockReason::PredepositExhausted));
388            }
389            if self.poisoned.load(Ordering::Acquire) {
390                return Err(GateClosed);
391            }
392            if !self.mode_is_current(mode_epoch) {
393                continue;
394            }
395            if self
396                .remaining
397                .compare_exchange(remaining, remaining - 1, Ordering::AcqRel, Ordering::Relaxed)
398                .is_ok()
399            {
400                // Predeposit first, then `served` with `Release`: the publication order
401                // `served_split` relies on, documented there.
402                self.served_predeposit.fetch_add(1, Ordering::Relaxed);
403                self.served.fetch_add(1, Ordering::Release);
404                return Ok(GateVerdict::Admitted);
405            }
406        }
407    }
408
409    /// Packets this gate has admitted, split into `(predeposit, funded)`.
410    ///
411    /// Counted on the predeposit branch only, and derived for the funded one. That asymmetry is the
412    /// whole point: at deployed dimensions the funded branch carries essentially all of a Session's
413    /// egress — tens of thousands of packets a second across a node — while the predeposit branch
414    /// carries at most `max_predeposit_packets` per front rotation. A second `fetch_add` on the
415    /// steady-state path would be paid on every packet to produce a number that subtraction already
416    /// gives exactly.
417    ///
418    /// # The publication order, and why it is that way round
419    ///
420    /// The two loads are not atomic with respect to each other, so a concurrent predeposit permit
421    /// can land between them and be seen by one load but not the other. Which of the two tears is
422    /// possible decides whether that permit is merely *late* or is actually *miscounted*, and the
423    /// writers pick which by the order they publish in.
424    ///
425    /// A predeposit permit therefore increments `served_predeposit` first and publishes `served`
426    /// with [`Release`](Ordering::Release). The acquire load of `served` below synchronizes with
427    /// that store, so seeing the permit in the total guarantees seeing it in the predeposit count
428    /// too: the derived funded figure can never come out one *high*, which would report a
429    /// predeposit packet as funded and then report it again as predeposit on the next read.
430    ///
431    /// The surviving tear is the harmless one — `served_predeposit` new against `served` old, which
432    /// makes the derived funded figure one *low*. Nothing is lost and the next read is consistent
433    /// again, but a consumer keeping a watermark must not store the regressed value or it will
434    /// re-cross that ground: see
435    /// [`PixSessionTelemetry::flush_egress`](super::telemetry::PixSessionTelemetry), which clamps.
436    pub fn served_split(&self) -> (u64, u64) {
437        let served = self.served.load(Ordering::Acquire);
438        let predeposit = self.served_predeposit.load(Ordering::Acquire);
439        (predeposit, served.saturating_sub(predeposit))
440    }
441}
442
443// ---------------------------------------------------------------------------
444// Tests
445// ---------------------------------------------------------------------------
446
447#[cfg(test)]
448mod tests {
449    use std::time::Duration;
450
451    use super::*;
452
453    /// Helper: create a gate with a generous ceiling for tests that don't
454    /// care about the ceiling behavior.
455    fn gate_with_ceiling(predeposit: u64) -> Arc<ServiceGate> {
456        ServiceGate::new(predeposit, u64::MAX)
457    }
458
459    /// `new` stores what it is given and starts both flags clear.
460    ///
461    /// Named after that and nothing more. The `min(target_useful_shares - 1, max_predeposit_packets)`
462    /// this used to claim to test is computed in `spawn_supervisor_worker`, not here — this file
463    /// cannot observe it — and is covered there by `zero_predeposit_config_reaches_the_gate_as_strict_prepay`
464    /// and `predeposit_budget_is_bounded_by_the_ssa_dimensions`.
465    #[tokio::test]
466    async fn new_stores_its_budget_and_ceiling_and_starts_unfunded() {
467        let gate = ServiceGate::new(100, 256);
468        assert_eq!(gate.remaining.load(Ordering::Acquire), 100);
469        assert_eq!(gate.ceiling.load(Ordering::Acquire), 256);
470        assert!(!gate.funded.load(Ordering::Acquire));
471        assert!(!gate.poisoned.load(Ordering::Acquire));
472    }
473
474    #[tokio::test]
475    async fn acquire_succeeds_within_budget() {
476        let gate = gate_with_ceiling(3);
477        for _ in 0..3 {
478            gate.acquire().await.unwrap();
479        }
480        assert_eq!(gate.served_total(), 3);
481    }
482
483    #[tokio::test]
484    async fn acquire_parks_when_predeposit_budget_exhausted() {
485        let gate = gate_with_ceiling(1);
486        gate.acquire().await.unwrap();
487
488        let gate_clone = gate.clone();
489        let parked =
490            tokio::spawn(async move { tokio::time::timeout(Duration::from_millis(200), gate_clone.acquire()).await });
491
492        let result = parked.await.unwrap();
493        assert!(result.is_err(), "expected timeout");
494    }
495
496    #[tokio::test]
497    async fn release_service_wakes_parked_writers() {
498        let gate = gate_with_ceiling(0); // No predeposit budget.
499        let gate_clone = gate.clone();
500
501        let parked = tokio::spawn(async move {
502            gate_clone.acquire().await.unwrap();
503            42u32
504        });
505
506        tokio::time::sleep(Duration::from_millis(20)).await;
507        gate.release_service();
508
509        let result = parked.await.unwrap();
510        assert_eq!(result, 42);
511        assert!(gate.funded.load(Ordering::Acquire));
512    }
513
514    /// Strict prepay: a gate with no predeposit budget serves nothing until it is funded.
515    ///
516    /// Several tests above already happen to use a zero budget to reach some other behaviour, but
517    /// none of them states this configuration's contract, which is the whole of what an Exit
518    /// choosing `max_predeposit_packets = 0` is buying. A change that admitted even one packet
519    /// before funding would pass every one of those and fail only here.
520    #[tokio::test]
521    async fn a_zero_budget_gate_serves_nothing_until_funded() {
522        let gate = ServiceGate::new(0, 10);
523
524        // Nothing, on either path.
525        assert_eq!(
526            GateVerdict::Blocked(GateBlockReason::PredepositExhausted),
527            gate.try_acquire_sync().expect("a fresh gate is not poisoned"),
528            "the synchronous path must refuse while unfunded with no budget"
529        );
530        assert!(
531            tokio::time::timeout(Duration::from_millis(100), gate.acquire())
532                .await
533                .is_err(),
534            "the async path must park rather than admit"
535        );
536        assert_eq!(gate.served_total(), 0, "a refused packet must not be counted as served");
537
538        // A writer parked before funding is woken by it, not left pending.
539        let parked = {
540            let gate = gate.clone();
541            tokio::spawn(async move { gate.acquire().await })
542        };
543        tokio::time::sleep(Duration::from_millis(20)).await;
544        gate.release_service();
545        parked
546            .await
547            .unwrap()
548            .expect("funding must wake the writer parked on an empty budget");
549
550        // Service is then ordinary, and the ceiling counts from funding rather than from a
551        // predeposit allowance that was never spent.
552        assert_eq!(
553            GateVerdict::Admitted,
554            gate.try_acquire_sync().expect("a funded gate is not poisoned")
555        );
556        assert_eq!(gate.served_total(), 2);
557    }
558
559    #[tokio::test]
560    async fn funded_gate_surrenders_at_ceiling() {
561        let gate = ServiceGate::new(0, 10); // Ceiling of 10.
562        gate.release_service();
563
564        // Serve up to the ceiling.
565        for _ in 0..10 {
566            gate.acquire().await.unwrap();
567        }
568
569        // 11th should park (ceiling exceeded).
570        let gate_clone = gate.clone();
571        let parked =
572            tokio::spawn(async move { tokio::time::timeout(Duration::from_millis(100), gate_clone.acquire()).await });
573        let result = parked.await.unwrap();
574        assert!(result.is_err(), "expected timeout due to ceiling");
575
576        assert_eq!(gate.served_total(), 10);
577    }
578
579    #[tokio::test]
580    async fn notify_progress_resets_ceiling() {
581        let gate = ServiceGate::new(0, 10);
582        gate.release_service();
583
584        for _ in 0..10 {
585            gate.acquire().await.unwrap();
586        }
587
588        // Progress resets the ceiling.
589        gate.notify_progress();
590
591        // Now serve another 10.
592        for _ in 0..10 {
593            gate.acquire().await.unwrap();
594        }
595        assert_eq!(gate.served_total(), 20);
596    }
597
598    #[tokio::test]
599    async fn notify_progress_wakes_ceiling_parked_writer() {
600        let gate = ServiceGate::new(0, 5);
601        gate.release_service();
602
603        for _ in 0..5 {
604            gate.acquire().await.unwrap();
605        }
606
607        let gate_clone = gate.clone();
608        let parked = tokio::spawn(async move {
609            gate_clone.acquire().await.unwrap();
610            42u32
611        });
612
613        tokio::time::sleep(Duration::from_millis(20)).await;
614        gate.notify_progress();
615
616        let result = parked.await.unwrap();
617        assert_eq!(result, 42);
618        assert_eq!(gate.served_total(), 6);
619    }
620
621    #[tokio::test]
622    async fn poison_errors_parked_and_future_acquires() {
623        let gate = gate_with_ceiling(0); // No predeposit budget → will park.
624        let gate_clone = gate.clone();
625
626        let parked = tokio::spawn(async move { gate_clone.acquire().await });
627
628        tokio::time::sleep(Duration::from_millis(20)).await;
629        gate.poison();
630
631        let result = parked.await.unwrap();
632        assert!(result.is_err());
633
634        // Future acquires also fail.
635        let gate_clone = gate.clone();
636        let result = gate_clone.acquire().await;
637        assert!(result.is_err());
638    }
639
640    #[tokio::test]
641    async fn served_total_is_monotonic_under_concurrency() {
642        let gate = gate_with_ceiling(1000);
643        let mut handles = Vec::new();
644
645        for _ in 0..10 {
646            let g = gate.clone();
647            handles.push(tokio::spawn(async move {
648                for _ in 0..100 {
649                    g.acquire().await.unwrap();
650                }
651            }));
652        }
653
654        for h in handles {
655            h.await.unwrap();
656        }
657
658        assert_eq!(gate.served_total(), 1000);
659    }
660
661    #[tokio::test]
662    async fn try_acquire_sync_succeeds_within_budget() {
663        let gate = gate_with_ceiling(5);
664        for _ in 0..5 {
665            assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync().unwrap());
666        }
667        assert_eq!(gate.served_total(), 5);
668    }
669
670    #[tokio::test]
671    async fn try_acquire_sync_reports_predeposit_exhaustion_when_the_budget_is_spent() {
672        let gate = gate_with_ceiling(2);
673        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync().unwrap());
674        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync().unwrap());
675        assert_eq!(
676            GateVerdict::Blocked(GateBlockReason::PredepositExhausted),
677            gate.try_acquire_sync().unwrap()
678        );
679        assert_eq!(gate.served_total(), 2);
680    }
681
682    #[tokio::test]
683    async fn try_acquire_sync_succeeds_after_funding() {
684        let gate = gate_with_ceiling(0);
685
686        assert_eq!(
687            GateVerdict::Blocked(GateBlockReason::PredepositExhausted),
688            gate.try_acquire_sync().unwrap()
689        );
690        gate.release_service();
691        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync().unwrap());
692        assert_eq!(gate.served_total(), 1);
693    }
694
695    #[tokio::test]
696    async fn try_acquire_sync_honors_ceiling_after_funding() {
697        let gate = ServiceGate::new(0, 5);
698        gate.release_service();
699
700        for _ in 0..5 {
701            assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync().unwrap());
702        }
703        // 6th should hit the ceiling.
704        assert_eq!(
705            GateVerdict::Blocked(GateBlockReason::ShareLag),
706            gate.try_acquire_sync().unwrap()
707        );
708
709        // Progress resets it.
710        gate.notify_progress();
711        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync().unwrap());
712    }
713
714    #[tokio::test]
715    async fn try_acquire_sync_errors_when_poisoned() {
716        let gate = gate_with_ceiling(10);
717        gate.poison();
718        assert!(gate.try_acquire_sync().is_err());
719    }
720
721    #[tokio::test]
722    async fn ceiling_check_uses_saturating_sub_from_watermark() {
723        // Pre-serve some packets via predeposit, then fund and check ceiling
724        // starts fresh from the watermark, not from 0.
725        let gate = ServiceGate::new(50, 10);
726        for _ in 0..30 {
727            gate.acquire().await.unwrap();
728        }
729        assert_eq!(gate.served_total(), 30);
730
731        gate.release_service();
732        gate.notify_progress(); // Watermark = 30, ceiling = 10.
733        assert_eq!(gate.served_at_last_progress.load(Ordering::Acquire), 30);
734
735        for _ in 0..10 {
736            gate.acquire().await.unwrap();
737        }
738        assert_eq!(gate.served_total(), 40);
739
740        // 41st should hit ceiling (40 - 30 >= 10).
741        let gate_clone = gate.clone();
742        let parked =
743            tokio::spawn(async move { tokio::time::timeout(Duration::from_millis(50), gate_clone.acquire()).await });
744        let result = parked.await.unwrap();
745        assert!(result.is_err(), "expected timeout due to ceiling");
746    }
747
748    // -------------------------------------------------------------------
749    // M-05: Funding watermark
750    // -------------------------------------------------------------------
751
752    #[tokio::test]
753    async fn release_service_snapshots_served_total_as_watermark() {
754        let gate = ServiceGate::new(50, 5);
755        for _ in 0..10 {
756            gate.acquire().await.unwrap();
757        }
758        assert_eq!(gate.served_total(), 10);
759
760        // release_service must snapshot served_total into served_at_last_progress.
761        gate.release_service();
762        assert_eq!(gate.served_at_last_progress.load(Ordering::Acquire), 10);
763    }
764
765    #[tokio::test]
766    async fn release_service_after_ceiling_predeposit_unblocks_waiter() {
767        // predeposit = 100, ceiling = 10.
768        let gate = ServiceGate::new(100, 10);
769
770        // Consume 30 predeposit packets — more than the ceiling.
771        for _ in 0..30 {
772            gate.acquire().await.unwrap();
773        }
774        assert_eq!(gate.served_total(), 30);
775
776        // Funding snapshots served=30 into the watermark. The ceiling check
777        // then sees 30 - 30 = 0 < 10, so the waiter is unblocked.
778        gate.release_service();
779        gate.acquire().await.unwrap();
780        assert_eq!(gate.served_total(), 31);
781    }
782
783    #[tokio::test]
784    async fn withholding_restores_the_predeposit_budget_for_the_next_paid_handoff() -> anyhow::Result<()> {
785        let gate = ServiceGate::new(2, 10);
786
787        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
788        gate.release_service();
789        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
790
791        gate.withhold_service();
792        assert!(!gate.funded());
793        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
794        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
795        assert_eq!(
796            GateVerdict::Blocked(GateBlockReason::PredepositExhausted),
797            gate.try_acquire_sync()?
798        );
799        Ok(())
800    }
801
802    #[tokio::test]
803    async fn strict_prepay_is_restored_when_service_is_withheld() -> anyhow::Result<()> {
804        let gate = ServiceGate::new(0, 10);
805        gate.release_service();
806        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
807
808        gate.withhold_service();
809        assert_eq!(
810            GateVerdict::Blocked(GateBlockReason::PredepositExhausted),
811            gate.try_acquire_sync()?
812        );
813        assert!(
814            tokio::time::timeout(Duration::from_millis(50), gate.acquire())
815                .await
816                .is_err(),
817            "the async path must park after a strict-prepay rotation too"
818        );
819        Ok(())
820    }
821
822    #[tokio::test]
823    async fn withholding_wakes_a_ceiling_parked_writer_into_the_new_allowance() -> anyhow::Result<()> {
824        let gate = ServiceGate::new(1, 1);
825        gate.release_service();
826        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
827
828        let mut parked = {
829            let gate = gate.clone();
830            tokio::spawn(async move { gate.acquire().await })
831        };
832        assert!(
833            tokio::time::timeout(Duration::from_millis(50), &mut parked)
834                .await
835                .is_err(),
836            "the writer must first park on the funded ceiling"
837        );
838
839        gate.withhold_service();
840        tokio::time::timeout(Duration::from_secs(1), parked).await???;
841        assert_eq!(gate.served_total(), 2);
842        Ok(())
843    }
844
845    /// Exposure tracks the current front's unpaid service, and funding is what clears it.
846    ///
847    /// Zero once funded is the contract rather than an approximation: funding converts the
848    /// allowance the front spent into service the Entry has paid for, so an Exit reading
849    /// `hopr_pix_predeposit_exposure_packets` sees only what it is still owed.
850    #[tokio::test]
851    async fn predeposit_exposure_tracks_the_current_front_and_clears_on_funding() -> anyhow::Result<()> {
852        let gate = ServiceGate::new(4, 10);
853        assert_eq!(0, gate.predeposit_exposure(), "nothing served, nothing exposed");
854
855        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
856        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
857        assert_eq!(2, gate.predeposit_exposure());
858
859        gate.release_service();
860        assert_eq!(
861            0,
862            gate.predeposit_exposure(),
863            "the deposit paid for what the allowance advanced"
864        );
865
866        // Funded service is not exposure however much of it there is.
867        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
868        assert_eq!(0, gate.predeposit_exposure());
869
870        // A paid handoff restores the whole allowance for an unfunded successor, and the successor's
871        // own exposure starts again from nothing rather than inheriting its predecessor's.
872        gate.withhold_service();
873        assert_eq!(0, gate.predeposit_exposure());
874        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
875        assert_eq!(1, gate.predeposit_exposure());
876        Ok(())
877    }
878
879    /// The served split attributes each packet to whatever paid for it, across a full rotation.
880    ///
881    /// The funded component is derived rather than counted, so the property worth stating is that
882    /// the derivation is exact: the two halves must always sum to `served_total`, whichever branch
883    /// admitted the packet and whichever of the two entry points it came through.
884    #[tokio::test]
885    async fn the_served_split_attributes_every_packet_to_what_paid_for_it() -> anyhow::Result<()> {
886        let gate = ServiceGate::new(3, 100);
887        assert_eq!((0, 0), gate.served_split());
888
889        // Two on the allowance, synchronously.
890        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
891        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
892        assert_eq!((2, 0), gate.served_split());
893
894        // One more on the allowance, through the async path — which is the one a writer woken by a
895        // handoff takes, and would be attributed to the wrong bucket if only the sync path counted.
896        gate.acquire().await?;
897        assert_eq!((3, 0), gate.served_split());
898
899        // Funding moves the accounting without rewriting history: the three already served stay
900        // charged to the allowance.
901        gate.release_service();
902        for _ in 0..5 {
903            assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
904        }
905        assert_eq!((3, 5), gate.served_split());
906
907        // A paid handoff restores the allowance, and the next packets are unpaid again.
908        gate.withhold_service();
909        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
910        assert_eq!((4, 5), gate.served_split());
911
912        let (predeposit, funded) = gate.served_split();
913        assert_eq!(
914            gate.served_total(),
915            predeposit + funded,
916            "the split must account for every packet the gate admitted"
917        );
918        Ok(())
919    }
920
921    /// A concurrent reader of the split never sees predeposit service as funded.
922    ///
923    /// The two loads in `served_split` are not atomic with respect to each other, so a permit
924    /// landing between them is visible to one and not the other. On a gate that has never been
925    /// funded the answer is knowable regardless: every packet came off the allowance, so the derived
926    /// funded figure must be zero at *every* observation, however the reads interleave.
927    ///
928    /// That is the invariant the predeposit path's publication order buys — `served_predeposit`
929    /// first, `served` released — and it is worth a test because the failure is silent: a reader
930    /// that saw the total move before the split would emit the packet as funded, and the watermark
931    /// on the other side cannot take that back.
932    ///
933    /// Note this cannot fail on x86, whose store ordering makes the wrong order accidentally
934    /// correct. It is the weaker targets and the compiler's own freedom to reorder two relaxed
935    /// read-modify-writes that this pins down.
936    #[tokio::test]
937    async fn a_concurrent_split_never_attributes_predeposit_service_to_the_funded_bucket() {
938        const PACKETS: u64 = 20_000;
939
940        let gate = ServiceGate::new(PACKETS, u64::MAX);
941        // Both sides are released together. Without it the admissions can be over before the
942        // sampling loop first looks, which would leave the test passing on nothing.
943        let start = Arc::new(std::sync::Barrier::new(2));
944        let admitting = {
945            let gate = gate.clone();
946            let start = start.clone();
947            std::thread::spawn(move || {
948                start.wait();
949                for _ in 0..PACKETS {
950                    assert_eq!(
951                        GateVerdict::Admitted,
952                        gate.try_acquire_sync().expect("an unpoisoned gate with allowance left")
953                    );
954                }
955            })
956        };
957        start.wait();
958
959        // Sample first, check for completion second, so the body runs whatever the scheduler does.
960        loop {
961            let (predeposit, funded) = gate.served_split();
962            assert_eq!(
963                0, funded,
964                "this gate was never funded, so every one of its {predeposit} packets came off the allowance — a \
965                 non-zero funded figure is a torn read being counted"
966            );
967            if admitting.is_finished() {
968                break;
969            }
970        }
971        admitting.join().expect("the admitting thread must not panic");
972
973        assert_eq!((PACKETS, 0), gate.served_split(), "and the final split is exact");
974    }
975
976    /// A strict-prepay gate has no allowance to expose, whatever happens to it.
977    #[tokio::test]
978    async fn a_zero_budget_gate_exposes_nothing() -> anyhow::Result<()> {
979        let gate = ServiceGate::new(0, 10);
980        assert_eq!(
981            GateVerdict::Blocked(GateBlockReason::PredepositExhausted),
982            gate.try_acquire_sync()?
983        );
984        assert_eq!(0, gate.predeposit_exposure());
985
986        gate.release_service();
987        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
988        assert_eq!(0, gate.predeposit_exposure());
989        Ok(())
990    }
991
992    #[tokio::test]
993    async fn a_new_funded_front_gets_a_fresh_progress_ceiling() -> anyhow::Result<()> {
994        let gate = ServiceGate::new(0, 2);
995        gate.release_service();
996        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
997        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
998        assert_eq!(
999            GateVerdict::Blocked(GateBlockReason::ShareLag),
1000            gate.try_acquire_sync()?
1001        );
1002
1003        // A funded-to-funded front handoff stays open but starts a new ceiling window.
1004        gate.release_service();
1005        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
1006        assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
1007        assert_eq!(
1008            GateVerdict::Blocked(GateBlockReason::ShareLag),
1009            gate.try_acquire_sync()?
1010        );
1011        Ok(())
1012    }
1013}