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}