1use std::time::{Duration, Instant};
9
10use hopr_api::{HoprBalance, OffchainPublicKey, types::internal::prelude::HoprPseudonym};
11use hopr_protocol_pix::{SsaId, SsaIndex, SsaRecoveryProgress};
12
13use super::{
14 PixParams, SessionPixAction, SessionPixCloseReason, SessionPixEvent, SupervisorConfig,
15 fill::{FillPlanner, FillTarget},
16 telemetry::{PixCycleOutcome, PixCyclePhase, PixCycleSummary, PixGateMode, PixSessionSnapshot, PixTurnEvents},
17};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24enum SsaPhase {
25 AwaitingCommitment,
27 AwaitingDeposit,
29 Recovering,
31 Recovered { tombstone_until: Instant },
34 Closing,
36}
37
38const WEI_PER_UHOPR: u128 = 1_000_000_000_000;
40
41fn deposit_uhopr(amount: HoprBalance) -> u64 {
46 let wei = amount.amount();
47 let wei = if wei.bits() > 128 { u128::MAX } else { wei.low_u128() };
48 (wei / WEI_PER_UHOPR).min(u64::MAX as u128) as u64
49}
50
51struct PerSsaState {
57 ssa_id: SsaId<HoprPseudonym>,
58 batch_id: u32,
60 phase: SsaPhase,
61
62 commitment_deadline: Option<Instant>,
64 recommit_deadline: Option<Instant>,
72 recommit_attempts: u8,
81 deposit_deadline: Option<Instant>,
82 recovery_idle_deadline: Option<Instant>,
83 recovery_hard_deadline: Option<Instant>,
84
85 deposit_uhopr: u64,
87 phase_entered_at: Instant,
89
90 largest_useful_shares: u64,
92 largest_shares_seen: u64,
96 target_useful_shares: u64,
97 recovered_polynomials: u16,
98
99 next_request_pending_deposit: bool,
101 next_requested: bool,
102 is_batch_last: bool,
118 recovered_pending: bool,
123
124 served_total_at_last_progress: u64,
126 served_total_at_front: Option<u64>,
136}
137
138impl PerSsaState {
139 fn new(ssa_id: SsaId<HoprPseudonym>, batch_id: u32, target_useful_shares: u64, now: Instant) -> Self {
140 Self {
141 ssa_id,
142 batch_id,
143 phase: SsaPhase::AwaitingCommitment,
144 phase_entered_at: now,
145 commitment_deadline: None,
146 recommit_deadline: None,
147 recommit_attempts: 0,
148 deposit_uhopr: 0,
149 deposit_deadline: None,
150 recovery_idle_deadline: None,
151 recovery_hard_deadline: None,
152 largest_useful_shares: 0,
153 largest_shares_seen: 0,
154 target_useful_shares,
155 recovered_polynomials: 0,
156 next_request_pending_deposit: false,
157 next_requested: false,
158 is_batch_last: false,
159 recovered_pending: false,
160 served_total_at_last_progress: 0,
161 served_total_at_front: None,
162 }
163 }
164
165 fn enter_phase(&mut self, phase: SsaPhase, now: Instant) -> Option<(PixCyclePhase, u64)> {
170 let left = match self.phase {
171 SsaPhase::AwaitingCommitment => Some(PixCyclePhase::AwaitingCommitment),
172 SsaPhase::AwaitingDeposit => Some(PixCyclePhase::AwaitingDeposit),
173 SsaPhase::Recovering => Some(PixCyclePhase::Recovering),
174 SsaPhase::Recovered { .. } | SsaPhase::Closing => None,
175 };
176 let elapsed = now.saturating_duration_since(self.phase_entered_at).as_millis() as u64;
177 self.phase = phase;
178 self.phase_entered_at = now;
179 left.map(|phase| (phase, elapsed))
180 }
181
182 fn telemetry_summary(&self, outcome: PixCycleOutcome, served_total: u64) -> PixCycleSummary {
184 PixCycleSummary {
185 outcome,
186 egress_packets: self
190 .served_total_at_front
191 .map(|base| served_total.saturating_sub(base))
192 .unwrap_or(0),
193 accepted_shares: self.largest_shares_seen,
194 useful_shares: self.largest_useful_shares,
195 target_useful_shares: self.target_useful_shares,
196 deposit_uhopr: self.deposit_uhopr,
197 }
198 }
199
200 fn is_terminal(&self) -> bool {
202 matches!(self.phase, SsaPhase::Recovered { .. } | SsaPhase::Closing)
203 }
204}
205
206struct PaidRecoveryTail {
214 ssa_id: SsaId<HoprPseudonym>,
215 entered_at: Instant,
217 largest_shares_seen: u64,
218 served_total_at_last_progress: u64,
219 idle_deadline: Option<Instant>,
220 hard_deadline: Option<Instant>,
221}
222
223pub struct SessionPixSupervisor {
229 pub(crate) cfg: SupervisorConfig,
230 pub(crate) dims: PixParams,
231 pub(crate) pseudonym: HoprPseudonym,
232 pub(crate) closed: bool,
233 pub(crate) draining: bool,
241 next_ssa_index: u32,
242 share_order_front: Option<SsaIndex>,
245 front_useful: u64,
247 off_front_useful: u64,
254 service_open: bool,
256 service_front: Option<SsaIndex>,
261 paid_front_handoff: bool,
264 paid_recovery_tail: Option<PaidRecoveryTail>,
270 successor_request_deferred: bool,
272 ssas: Vec<PerSsaState>,
274 first_failure_reason: Option<SessionPixCloseReason>,
277 failed_cycles: usize,
284 fill: FillPlanner,
290 highest_retired_ssa_index: Option<SsaIndex>,
302 telemetry: PixTurnEvents,
309}
310
311impl SessionPixSupervisor {
312 pub fn new(
314 cfg: SupervisorConfig,
315 dims: PixParams,
316 pseudonym: HoprPseudonym,
317 now: Instant,
318 ) -> (Self, Vec<SessionPixAction>) {
319 let mut s = Self {
320 fill: FillPlanner::new(&cfg, &dims, now),
321 cfg,
322 dims,
323 pseudonym,
324 next_ssa_index: 1,
325 share_order_front: None,
326 front_useful: 0,
327 off_front_useful: 0,
328 closed: false,
329 draining: false,
330 service_open: false,
331 service_front: None,
332 paid_front_handoff: false,
333 paid_recovery_tail: None,
334 successor_request_deferred: false,
335 ssas: Vec::with_capacity(2),
336 first_failure_reason: None,
337 failed_cycles: 0,
338 highest_retired_ssa_index: None,
339 telemetry: PixTurnEvents::default(),
340 };
341
342 let actions = s.emit_request_next_ssa(now);
343 s.refresh_share_order_front();
344 (s, actions)
345 }
346
347 pub fn handle_event(&mut self, ev: &SessionPixEvent, now: Instant, served_total: u64) -> Vec<SessionPixAction> {
349 if self.closed {
350 return Vec::new();
351 }
352
353 let actions = match ev {
354 SessionPixEvent::SsaRequestSent(ssa_id) => self.on_ssa_request_sent(ssa_id, now),
355 SessionPixEvent::CommitmentProgress(ssa_id) => self.on_commitment_progress(ssa_id, now),
356 SessionPixEvent::CommitmentVerified(ssa_id) => self.on_commitment_verified(ssa_id, now),
357 SessionPixEvent::DepositConfirmed { ssa_id, amount } => {
358 self.on_deposit_confirmed(ssa_id, *amount, now, served_total)
359 }
360 SessionPixEvent::DepositObserverClosed(ssa_id) => {
361 self.on_deposit_observer_closed(ssa_id, now, served_total)
362 }
363 SessionPixEvent::RecoveryProgress(progress) => self.on_recovery_progress(progress, now, served_total),
364 SessionPixEvent::AlmostRecovered(ssa_id) => self.on_almost_recovered(ssa_id, now),
365 SessionPixEvent::Recovered(ssa_id) => self.on_recovered(ssa_id, now, served_total),
366 SessionPixEvent::UnverifiableShares {
367 ssa_id,
368 observed_total,
369 peer,
370 } => self.on_unverifiable_shares(ssa_id, *observed_total, peer, now),
371 SessionPixEvent::SessionClosed { drainable_surbs } => self.on_session_closed(*drainable_surbs, now),
372 };
373
374 let mut lifecycle_actions = actions;
375 lifecycle_actions.extend(self.retry_deferred_successor_request(now));
376 self.consume_paid_tail_at_observed_boundary(now);
377 self.arm_recovery_clocks_for_earliest(now, served_total);
378 let mut actions = if lifecycle_actions
379 .iter()
380 .any(|action| matches!(action, SessionPixAction::Close(_)))
381 {
382 self.fill
387 .stop()
388 .map(SessionPixAction::SetFillRate)
389 .into_iter()
390 .collect()
391 } else {
392 self.sync_service_gate()
393 };
394 actions.extend(lifecycle_actions);
395 self.refresh_share_order_front();
396 actions
397 }
398
399 fn note_retired_index(&mut self, index: SsaIndex) {
405 self.highest_retired_ssa_index = Some(match self.highest_retired_ssa_index {
406 Some(highest) if highest >= index => highest,
407 _ => index,
408 });
409 }
410
411 fn earliest_live_idx(&self) -> Option<usize> {
414 self.ssas
415 .iter()
416 .enumerate()
417 .filter(|(_, s)| !s.is_terminal())
418 .min_by_key(|(_, s)| s.ssa_id.ssa_index())
419 .map(|(i, _)| i)
420 }
421
422 fn release_paid_tail(&mut self, now: Instant) {
431 if let Some(tail) = self.paid_recovery_tail.take() {
432 let elapsed = now.saturating_duration_since(tail.entered_at).as_millis() as u64;
433 self.telemetry.phase_durations.push((PixCyclePhase::PaidTail, elapsed));
434 }
435 }
436
437 fn consume_paid_tail_at_observed_boundary(&mut self, now: Instant) {
438 let Some(front_idx) = self.earliest_live_idx() else {
439 return;
440 };
441 let front_id = self.ssas[front_idx].ssa_id;
442 if self.ssas[front_idx].largest_shares_seen == 0 {
443 return;
444 }
445
446 if let Some(predecessor) = self
447 .paid_recovery_tail
448 .as_ref()
449 .map(|tail| tail.ssa_id)
450 .filter(|predecessor| predecessor.ssa_index() < front_id.ssa_index())
451 {
452 tracing::debug!(
453 %predecessor,
454 successor = %front_id,
455 "successor progress consumed the paid FIFO-tail handoff"
456 );
457 self.release_paid_tail(now);
458 }
459 }
460
461 fn sync_service_gate(&mut self) -> Vec<SessionPixAction> {
468 if self.closed {
469 return Vec::new();
470 }
471
472 let front = self.earliest_live_idx();
473 let front_id = self
474 .paid_recovery_tail
475 .as_ref()
476 .map(|tail| tail.ssa_id.ssa_index())
477 .or_else(|| front.map(|idx| self.ssas[idx].ssa_id.ssa_index()));
478 let front_changed = front_id != self.service_front;
479 let front_funded =
480 self.paid_recovery_tail.is_some() || front.is_some_and(|idx| self.ssas[idx].phase == SsaPhase::Recovering);
481 let paid_front_handoff = std::mem::take(&mut self.paid_front_handoff);
482 self.service_front = front_id;
483
484 match (front_funded, self.service_open, front_changed) {
485 (true, false, _) | (true, true, true) => {
486 self.service_open = true;
487 tracing::debug!(?front_id, "aligning service gate with funded front cycle");
488 vec![SessionPixAction::ReleaseService]
489 }
490 (false, true, _) => {
491 self.service_open = false;
492 tracing::debug!(?front_id, "withholding service for unfunded front cycle");
493 vec![SessionPixAction::WithholdService]
494 }
495 (false, false, _) if paid_front_handoff => {
496 tracing::debug!(?front_id, "restoring predeposit service after paid front handoff");
497 vec![SessionPixAction::WithholdService]
498 }
499 _ => Vec::new(),
500 }
501 }
502
503 pub fn telemetry_snapshot(&self, predeposit_exposure: u64) -> PixSessionSnapshot {
513 let mut snapshot = PixSessionSnapshot {
514 gate_mode: if self.service_open {
518 PixGateMode::Funded
519 } else {
520 PixGateMode::Predeposit
521 },
522 paid_tail: u32::from(self.paid_recovery_tail.is_some()),
523 predeposit_exposure_packets: predeposit_exposure,
524 stalled_share: u32::from(self.fill.is_stalled()),
525 ..Default::default()
526 };
527
528 for ssa in self.ssas.iter() {
529 let bucket = match ssa.phase {
533 SsaPhase::AwaitingCommitment => &mut snapshot.awaiting_commitment,
534 SsaPhase::AwaitingDeposit => &mut snapshot.awaiting_deposit,
535 SsaPhase::Recovering => &mut snapshot.recovering,
536 SsaPhase::Recovered { .. } | SsaPhase::Closing => continue,
537 };
538 *bucket = bucket.saturating_add(1);
539 }
540
541 snapshot
542 }
543
544 pub fn take_telemetry_events(&mut self) -> PixTurnEvents {
546 std::mem::take(&mut self.telemetry)
547 }
548
549 fn live_cycle_count(&self) -> usize {
550 self.ssas.iter().filter(|ssa| !ssa.is_terminal()).count()
551 }
552
553 fn live_batch_count(&self) -> usize {
554 let mut batches = Vec::with_capacity(crate::MAX_OVERLAPPING_BATCHES as usize);
555 for ssa in self.ssas.iter().filter(|ssa| !ssa.is_terminal()) {
556 if !batches.contains(&ssa.batch_id) {
557 batches.push(ssa.batch_id);
558 }
559 }
560 batches.len()
561 }
562
563 fn reserved_cycle_slots(&self) -> usize {
564 self.cfg
565 .ssas_per_request
566 .clamp(1, crate::MAX_SSA_BATCH_SIZE)
567 .saturating_mul(crate::MAX_OVERLAPPING_BATCHES as usize)
568 }
569
570 fn retry_deferred_successor_request(&mut self, now: Instant) -> Vec<SessionPixAction> {
571 if self.closed || !self.successor_request_deferred {
572 return Vec::new();
573 }
574
575 self.successor_request_deferred = false;
576 self.emit_request_next_ssa(now)
577 }
578
579 fn refresh_share_order_front(&mut self) {
589 let front = self.earliest_live_idx().map(|i| self.ssas[i].ssa_id.ssa_index());
590 if front != self.share_order_front {
591 self.share_order_front = front;
592 self.front_useful = 0;
593 self.off_front_useful = 0;
594 }
595 }
596
597 fn arm_recovery_clocks_for_earliest(&mut self, now: Instant, served_total: u64) {
616 if self.closed {
617 return;
618 }
619 if self.paid_recovery_tail.is_some() {
623 return;
624 }
625 let Some(idx) = self.earliest_live_idx() else {
626 return;
627 };
628
629 let ssa = &mut self.ssas[idx];
630 if ssa.phase != SsaPhase::Recovering || ssa.recovery_hard_deadline.is_some() {
631 return;
632 }
633
634 ssa.recovery_idle_deadline = now.checked_add(self.cfg.max_recovery_idle);
635 ssa.recovery_hard_deadline = now.checked_add(self.cfg.max_recovery_time);
636 ssa.served_total_at_last_progress = served_total;
637 ssa.served_total_at_front.get_or_insert(served_total);
643 tracing::debug!(ssa_id = %ssa.ssa_id, "recovery clocks started — cycle is at the front of the batch");
644 }
645
646 pub fn handle_deadline(&mut self, now: Instant, served_total: u64) -> Vec<SessionPixAction> {
648 if self.closed {
649 return Vec::new();
650 }
651
652 let mut actions = Vec::new();
653 let max_recovery_idle = self.cfg.max_recovery_idle;
654
655 if let Some(tail) = self.paid_recovery_tail.as_mut() {
660 let expired = if tail.hard_deadline.is_some_and(|deadline| now >= deadline) {
661 Some(SessionPixCloseReason::RecoveryDeadline)
662 } else if tail.idle_deadline.is_some_and(|deadline| now >= deadline) {
663 Some(SessionPixCloseReason::RecoveryIdle)
664 } else {
665 None
666 };
667
668 if let Some(reason) = expired {
669 if reason == SessionPixCloseReason::RecoveryIdle && served_total <= tail.served_total_at_last_progress {
670 tail.idle_deadline = now.checked_add(max_recovery_idle);
671 } else {
672 tracing::warn!(
673 ssa_id = %tail.ssa_id,
674 ?reason,
675 largest_shares_seen = tail.largest_shares_seen,
676 served_total_at_last_progress = tail.served_total_at_last_progress,
677 "closing PIX Session while its paid recovery tail is stalled"
678 );
679 self.release_paid_tail(now);
682 self.closed = true;
683 return self.with_fill_stopped(vec![SessionPixAction::Close(reason)]);
684 }
685 }
686 }
687
688 let mut i = 0;
689 while i < self.ssas.len() {
690 if self.closed {
691 break;
692 }
693
694 let expired = {
695 let ssa = &self.ssas[i];
696 if ssa.is_terminal() {
697 i += 1;
698 continue;
699 }
700 ssa.check_deadlines(now)
701 };
702
703 if expired.is_none()
709 && self.ssas[i].phase == SsaPhase::AwaitingCommitment
710 && self.ssas[i].recommit_deadline.is_some_and(|deadline| now >= deadline)
711 {
712 let ssa = &mut self.ssas[i];
713 ssa.recommit_attempts += 1;
714 ssa.recommit_deadline = (ssa.recommit_attempts < hopr_protocol_pix::MAX_COMMITMENT_RETRANSMISSIONS)
717 .then(|| now.checked_add(self.cfg.commitment_recommit_interval))
718 .flatten();
719 tracing::debug!(
720 ssa_id = %ssa.ssa_id, attempt = ssa.recommit_attempts,
721 "commitment delivery stalled — re-requesting the parts that did not arrive"
722 );
723 actions.push(SessionPixAction::RequestCommitmentRetransmission {
724 ssa_id: ssa.ssa_id,
725 params: self.dims,
726 });
727 }
728
729 if let Some(reason) = expired {
730 if reason == SessionPixCloseReason::RecoveryIdle
740 && !self.draining
741 && served_total <= self.ssas[i].served_total_at_last_progress
742 {
743 self.ssas[i].recovery_idle_deadline = now.checked_add(max_recovery_idle);
744 i += 1;
745 continue;
746 }
747
748 actions.extend(self.close_ssa_and_collect(i, reason, served_total, now));
749 continue;
750 }
751 i += 1;
752 }
753
754 if self.closed {
757 return self.with_fill_stopped(actions);
758 }
759
760 let retired_ids: Vec<SsaId<_>> = self
763 .ssas
764 .iter()
765 .filter(|ssa| matches!(ssa.phase, SsaPhase::Recovered { tombstone_until } if now >= tombstone_until))
766 .map(|ssa| ssa.ssa_id)
767 .collect();
768 self.ssas
769 .retain(|ssa| !matches!(ssa.phase, SsaPhase::Recovered { tombstone_until } if now >= tombstone_until));
770 for id in retired_ids {
771 self.note_retired_index(id.ssa_index());
772 actions.push(SessionPixAction::RetireSsa(id));
773 }
774
775 actions.extend(self.retry_deferred_successor_request(now));
779 self.consume_paid_tail_at_observed_boundary(now);
780
781 if self.ssas.is_empty() && !self.closed {
789 actions.push(SessionPixAction::Close(
790 self.first_failure_reason
791 .unwrap_or(SessionPixCloseReason::NoSsaRemaining),
792 ));
793 self.closed = true;
794 }
795
796 self.arm_recovery_clocks_for_earliest(now, served_total);
800 let mut gate_actions = self.sync_service_gate();
801 gate_actions.extend(actions);
802 self.refresh_share_order_front();
803
804 if self.closed {
805 return self.with_fill_stopped(gate_actions);
806 }
807 gate_actions
808 }
809
810 pub fn handle_timers(&mut self, now: Instant, served_total: u64) -> Vec<SessionPixAction> {
820 let mut actions = self.handle_deadline(now, served_total);
821 if self.closed {
822 return actions;
823 }
824 if self.next_fill_tick().is_some_and(|tick| now >= tick) {
825 actions.extend(self.handle_fill_tick(now, served_total));
826 }
827 actions
828 }
829
830 pub fn handle_fill_tick(&mut self, now: Instant, served_total: u64) -> Vec<SessionPixAction> {
832 if self.closed {
833 return Vec::new();
834 }
835 let target = self.fill_target();
836 self.fill
837 .plan(now, served_total, target)
838 .map(|rate| vec![SessionPixAction::SetFillRate(rate)])
839 .into_iter()
840 .flatten()
841 .collect()
842 }
843
844 pub fn take_fill_stall(&mut self) -> bool {
849 self.fill.take_stall_onset()
850 }
851
852 pub fn next_fill_tick(&self) -> Option<Instant> {
859 if self.closed || !self.fill.is_enabled() {
860 return None;
861 }
862 if self.fill_target().is_none() && self.fill.is_idle() {
863 return None;
864 }
865 Some(self.fill.next_tick())
866 }
867
868 fn fill_target(&self) -> Option<FillTarget> {
881 if let Some(tail) = self.paid_recovery_tail.as_ref() {
882 return tail.hard_deadline.map(|hard_deadline| FillTarget {
883 ssa_id: tail.ssa_id,
884 largest_shares_seen: tail.largest_shares_seen,
885 hard_deadline,
886 drain: self.draining,
887 });
888 }
889
890 let ssa = &self.ssas[self.earliest_live_idx()?];
891 (ssa.phase == SsaPhase::Recovering)
892 .then_some(ssa)
893 .and_then(|ssa| ssa.recovery_hard_deadline.map(|hard_deadline| (ssa, hard_deadline)))
894 .map(|(ssa, hard_deadline)| FillTarget {
895 ssa_id: ssa.ssa_id,
896 largest_shares_seen: ssa.largest_shares_seen,
897 hard_deadline,
898 drain: self.draining,
899 })
900 }
901
902 fn with_fill_stopped(&mut self, actions: Vec<SessionPixAction>) -> Vec<SessionPixAction> {
908 match self.fill.stop() {
909 Some(rate) => {
910 let mut stopped = Vec::with_capacity(actions.len() + 1);
911 stopped.push(SessionPixAction::SetFillRate(rate));
912 stopped.extend(actions);
913 stopped
914 }
915 None => actions,
916 }
917 }
918
919 pub fn next_deadline(&self) -> Option<Instant> {
921 if self.closed || (self.ssas.is_empty() && self.paid_recovery_tail.is_none()) {
922 return None;
923 }
924
925 let ssa_deadline = self
926 .ssas
927 .iter()
928 .filter_map(|ssa| {
929 if ssa.is_terminal() {
930 if let SsaPhase::Recovered { tombstone_until } = ssa.phase {
931 return Some(tombstone_until);
932 }
933 return None;
934 }
935 ssa.commitment_deadline
940 .into_iter()
941 .chain(ssa.recommit_deadline)
942 .chain(ssa.deposit_deadline)
943 .chain(ssa.recovery_idle_deadline)
944 .chain(ssa.recovery_hard_deadline)
945 .min()
946 })
947 .min();
948 let tail_deadline = self
949 .paid_recovery_tail
950 .as_ref()
951 .and_then(|tail| tail.idle_deadline.into_iter().chain(tail.hard_deadline).min());
952
953 ssa_deadline.into_iter().chain(tail_deadline).min()
954 }
955
956 pub fn action_result(&mut self, action: &SessionPixAction, ok: bool, _now: Instant) -> Vec<SessionPixAction> {
958 if self.closed {
959 return Vec::new();
960 }
961
962 match action {
963 SessionPixAction::RequestSsa { .. } if !ok => {
964 self.closed = true;
965 self.with_fill_stopped(vec![SessionPixAction::Close(
966 SessionPixCloseReason::SupervisorUnavailable,
967 )])
968 }
969 SessionPixAction::Close(_) => {
970 self.closed = true;
971 Vec::new()
972 }
973 _ => Vec::new(),
974 }
975 }
976
977 fn on_ssa_request_sent(&mut self, ssa_id: &SsaId<HoprPseudonym>, now: Instant) -> Vec<SessionPixAction> {
987 if ssa_id.pseudonym() != &self.pseudonym {
990 return vec![SessionPixAction::Close(SessionPixCloseReason::InvalidTransition)];
991 }
992
993 let Some(idx) = self.find_ssa_idx(ssa_id) else {
999 if self
1002 .highest_retired_ssa_index
1003 .is_some_and(|highest| ssa_id.ssa_index() <= highest)
1004 {
1005 return Vec::new();
1006 }
1007 return vec![SessionPixAction::Close(SessionPixCloseReason::InvalidTransition)];
1008 };
1009
1010 if self.ssas[idx].phase == SsaPhase::AwaitingCommitment && self.ssas[idx].commitment_deadline.is_none() {
1013 self.ssas[idx].commitment_deadline = now.checked_add(crate::supervision::scaled_deadline(
1014 self.cfg.max_ssa_delivery_time,
1015 self.cfg.ssas_per_request,
1016 ));
1017 }
1018
1019 Vec::new()
1020 }
1021
1022 fn on_commitment_progress(&mut self, ssa_id: &SsaId<HoprPseudonym>, now: Instant) -> Vec<SessionPixAction> {
1033 if let Some(idx) = self.find_ssa_idx(ssa_id)
1034 && self.ssas[idx].phase == SsaPhase::AwaitingCommitment
1035 && self.ssas[idx].recommit_attempts < hopr_protocol_pix::MAX_COMMITMENT_RETRANSMISSIONS
1036 {
1037 self.ssas[idx].recommit_deadline = now.checked_add(self.cfg.commitment_recommit_interval);
1038 }
1039
1040 Vec::new()
1041 }
1042
1043 fn on_commitment_verified(&mut self, ssa_id: &SsaId<HoprPseudonym>, now: Instant) -> Vec<SessionPixAction> {
1044 let idx = match self.find_ssa_idx(ssa_id) {
1045 Some(i) => i,
1046 None => return Vec::new(),
1047 };
1048 let ssa = &mut self.ssas[idx];
1049 if ssa.phase != SsaPhase::AwaitingCommitment {
1050 return Vec::new();
1051 }
1052
1053 let deposit_deadline = now.checked_add(crate::supervision::scaled_deadline(
1054 self.cfg.max_deposit_wait,
1055 self.cfg.ssas_per_request,
1056 ));
1057 let phase_sample = ssa.enter_phase(SsaPhase::AwaitingDeposit, now);
1058 ssa.deposit_deadline = deposit_deadline;
1059 ssa.commitment_deadline = None;
1060 ssa.recommit_deadline = None;
1062 self.telemetry.phase_durations.extend(phase_sample);
1063
1064 self.telemetry.committed = self.telemetry.committed.saturating_add(1);
1067
1068 Vec::new()
1069 }
1070
1071 fn on_deposit_confirmed(
1090 &mut self,
1091 ssa_id: &SsaId<HoprPseudonym>,
1092 amount: HoprBalance,
1093 now: Instant,
1094 served_total: u64,
1095 ) -> Vec<SessionPixAction> {
1096 let idx = match self.find_ssa_idx(ssa_id) {
1097 Some(i) => i,
1098 None => return Vec::new(),
1099 };
1100 let was_front = self.earliest_live_idx() == Some(idx);
1101
1102 let ssa = &mut self.ssas[idx];
1103 if ssa.phase != SsaPhase::AwaitingDeposit {
1104 return Vec::new();
1105 }
1106
1107 if amount == HoprBalance::zero() {
1108 return Vec::new();
1109 }
1110
1111 let phase_sample = ssa.enter_phase(SsaPhase::Recovering, now);
1114 ssa.deposit_deadline = None;
1115 ssa.served_total_at_last_progress = served_total;
1116 self.telemetry.phase_durations.extend(phase_sample);
1117
1118 ssa.deposit_uhopr = deposit_uhopr(amount);
1121 self.telemetry.funded = self.telemetry.funded.saturating_add(1);
1122 self.telemetry.deposit_confirmed_uhopr =
1123 self.telemetry.deposit_confirmed_uhopr.saturating_add(ssa.deposit_uhopr);
1124
1125 let recovered_pending = ssa.recovered_pending;
1128
1129 let pending = ssa.next_request_pending_deposit;
1130 if pending {
1131 ssa.next_request_pending_deposit = false;
1132 }
1133 let _ = ssa;
1135
1136 let mut actions = Vec::new();
1137
1138 if recovered_pending {
1139 if was_front {
1140 self.paid_front_handoff = true;
1141 }
1142 actions.extend(self.perform_recovered_transition(idx, now, served_total));
1143 }
1144
1145 if pending {
1146 actions.extend(self.emit_request_next_ssa(now));
1150 }
1151
1152 actions
1153 }
1154
1155 fn on_deposit_observer_closed(
1156 &mut self,
1157 ssa_id: &SsaId<HoprPseudonym>,
1158 now: Instant,
1159 served_total: u64,
1160 ) -> Vec<SessionPixAction> {
1161 let idx = match self.find_ssa_idx(ssa_id) {
1162 Some(i) => i,
1163 None => return Vec::new(),
1164 };
1165
1166 if self.ssas[idx].phase != SsaPhase::AwaitingDeposit {
1167 return Vec::new();
1168 }
1169
1170 self.close_ssa_and_collect(idx, SessionPixCloseReason::DepositObserverClosed, served_total, now)
1171 }
1172
1173 fn on_recovery_progress(
1174 &mut self,
1175 progress: &SsaRecoveryProgress<HoprPseudonym>,
1176 now: Instant,
1177 served_total: u64,
1178 ) -> Vec<SessionPixAction> {
1179 if self
1180 .paid_recovery_tail
1181 .as_ref()
1182 .is_some_and(|tail| tail.ssa_id == progress.ssa_id)
1183 {
1184 return self.on_recovered_tail_progress(progress, now, served_total);
1185 }
1186
1187 let idx = match self.find_ssa_idx(&progress.ssa_id) {
1188 Some(i) => i,
1189 None => return Vec::new(),
1190 };
1191
1192 if self.ssas[idx].is_terminal() {
1195 return Vec::new();
1196 }
1197
1198 let target = self.dims.target_useful_shares();
1203 let max_shares_seen = self.dims.polys_per_ssa() as u64 * self.dims.emitted_shares_per_poly() as u64;
1204 if progress.target_useful_shares != target
1205 || progress.useful_shares > target
1206 || progress.shares_seen < progress.useful_shares
1207 || progress.shares_seen > max_shares_seen
1208 || progress.recovered_polynomials > self.dims.polys_per_ssa()
1209 {
1210 return vec![SessionPixAction::Close(SessionPixCloseReason::CounterRegression)];
1211 }
1212
1213 let ssa = &mut self.ssas[idx];
1214 let new_useful = progress.useful_shares;
1215 let new_seen = progress.shares_seen;
1216
1217 if new_seen <= ssa.largest_shares_seen {
1225 return Vec::new();
1226 }
1227
1228 let accepted_delta = new_seen - ssa.largest_shares_seen;
1235 let useful_delta = new_useful.saturating_sub(ssa.largest_useful_shares).min(accepted_delta);
1242 self.telemetry.useful_shares = self.telemetry.useful_shares.saturating_add(useful_delta);
1243 self.telemetry.surplus_shares = self
1244 .telemetry
1245 .surplus_shares
1246 .saturating_add(accepted_delta - useful_delta);
1247
1248 ssa.largest_shares_seen = new_seen;
1249
1250 ssa.served_total_at_last_progress = served_total;
1256 if ssa.phase == SsaPhase::Recovering && ssa.recovery_hard_deadline.is_some() {
1264 ssa.recovery_idle_deadline = now.checked_add(self.cfg.max_recovery_idle);
1265 }
1266
1267 if new_useful > ssa.largest_useful_shares {
1270 let delta = new_useful - ssa.largest_useful_shares;
1271 ssa.largest_useful_shares = new_useful;
1272 ssa.recovered_polynomials = progress.recovered_polynomials;
1273
1274 if let Some(reason) = self.book_share_order_progress(&progress.ssa_id, delta) {
1275 return vec![SessionPixAction::Close(reason)];
1276 }
1277 }
1278
1279 let is_front = self.earliest_live_idx() == Some(idx);
1280
1281 if self.ssas[idx].phase == SsaPhase::Recovering && is_front {
1286 vec![SessionPixAction::ProgressNotification]
1287 } else {
1288 Vec::new()
1289 }
1290 }
1291
1292 fn on_recovered_tail_progress(
1299 &mut self,
1300 progress: &SsaRecoveryProgress<HoprPseudonym>,
1301 now: Instant,
1302 served_total: u64,
1303 ) -> Vec<SessionPixAction> {
1304 let target = self.dims.target_useful_shares();
1305 let expected_polynomials = self.dims.polys_per_ssa();
1306 let max_shares_seen = expected_polynomials as u64 * self.dims.emitted_shares_per_poly() as u64;
1307
1308 if progress.target_useful_shares != target
1309 || progress.useful_shares > target
1310 || progress.recovered_polynomials > expected_polynomials
1311 || progress.shares_seen < progress.useful_shares
1312 || progress.shares_seen > max_shares_seen
1313 {
1314 tracing::warn!(
1315 ssa_id = %progress.ssa_id,
1316 useful_shares = progress.useful_shares,
1317 shares_seen = progress.shares_seen,
1318 recovered_polynomials = progress.recovered_polynomials,
1319 "rejected impossible progress from a recovered PIX tail"
1320 );
1321 return vec![SessionPixAction::Close(SessionPixCloseReason::CounterRegression)];
1322 }
1323
1324 if progress.useful_shares != target || progress.recovered_polynomials != expected_polynomials {
1329 return Vec::new();
1330 }
1331
1332 let (exhausted, accepted_delta) = {
1333 let tail = self
1334 .paid_recovery_tail
1335 .as_mut()
1336 .expect("caller matched the paid recovery tail");
1337 if progress.shares_seen <= tail.largest_shares_seen {
1338 return Vec::new();
1339 }
1340
1341 let accepted_delta = progress.shares_seen - tail.largest_shares_seen;
1342 tail.largest_shares_seen = progress.shares_seen;
1343 tail.served_total_at_last_progress = served_total;
1344 tail.idle_deadline = now.checked_add(self.cfg.max_recovery_idle);
1345 (progress.shares_seen == max_shares_seen, accepted_delta)
1346 };
1347
1348 self.telemetry.surplus_shares = self.telemetry.surplus_shares.saturating_add(accepted_delta);
1354
1355 if let Some(idx) = self.find_ssa_idx(&progress.ssa_id) {
1358 self.ssas[idx].largest_useful_shares = target;
1359 self.ssas[idx].largest_shares_seen = progress.shares_seen;
1360 self.ssas[idx].recovered_polynomials = expected_polynomials;
1361 self.ssas[idx].served_total_at_last_progress = served_total;
1362 }
1363
1364 if exhausted {
1365 self.release_paid_tail(now);
1366 }
1367 vec![SessionPixAction::ProgressNotification]
1368 }
1369
1370 fn book_share_order_progress(
1378 &mut self,
1379 ssa_id: &SsaId<HoprPseudonym>,
1380 delta: u64,
1381 ) -> Option<SessionPixCloseReason> {
1382 let front = self.share_order_front?;
1383 if ssa_id.ssa_index() == front {
1384 self.front_useful = self.front_useful.saturating_add(delta);
1385 } else {
1386 self.off_front_useful = self.off_front_useful.saturating_add(delta);
1387 }
1388
1389 let total = self.front_useful.saturating_add(self.off_front_useful);
1393 if total < self.cfg.min_share_order_sample || total == 0 {
1394 return None;
1395 }
1396
1397 let fraction = self.off_front_useful as f64 / total as f64;
1398 if fraction > self.cfg.max_off_front_share_fraction {
1399 tracing::error!(
1400 %ssa_id, %front, fraction,
1401 limit = self.cfg.max_off_front_share_fraction,
1402 front_useful = self.front_useful,
1403 off_front_useful = self.off_front_useful,
1404 "entry is serving the batch out of order"
1405 );
1406 return Some(SessionPixCloseReason::BatchServedOutOfOrder);
1407 }
1408
1409 tracing::trace!(%ssa_id, %front, fraction, "progress behind the front of the batch");
1410 None
1411 }
1412
1413 fn on_almost_recovered(&mut self, ssa_id: &SsaId<HoprPseudonym>, now: Instant) -> Vec<SessionPixAction> {
1414 let idx = match self.find_ssa_idx(ssa_id) {
1415 Some(i) => i,
1416 None => return Vec::new(),
1417 };
1418
1419 if self.ssas[idx].is_terminal() {
1423 return Vec::new();
1424 }
1425
1426 if !self.ssas[idx].is_batch_last {
1430 return Vec::new();
1431 }
1432
1433 let next_requested = self.ssas[idx].next_requested;
1434 let phase = self.ssas[idx].phase;
1435
1436 if next_requested {
1437 return Vec::new();
1438 }
1439
1440 match phase {
1441 SsaPhase::Recovering => {
1442 self.ssas[idx].next_requested = true;
1443 self.emit_request_next_ssa(now)
1444 }
1445 SsaPhase::AwaitingDeposit => {
1446 self.ssas[idx].next_requested = true;
1447 self.ssas[idx].next_request_pending_deposit = true;
1448 Vec::new()
1449 }
1450 _ => {
1455 self.ssas[idx].next_request_pending_deposit = true;
1456 self.ssas[idx].next_requested = true;
1457 Vec::new()
1458 }
1459 }
1460 }
1461
1462 fn on_recovered(
1463 &mut self,
1464 ssa_id: &SsaId<HoprPseudonym>,
1465 now: Instant,
1466 served_total: u64,
1467 ) -> Vec<SessionPixAction> {
1468 let idx = match self.find_ssa_idx(ssa_id) {
1469 Some(i) => i,
1470 None => return Vec::new(),
1471 };
1472
1473 if self.ssas[idx].is_terminal() {
1476 return Vec::new();
1477 }
1478
1479 match self.ssas[idx].phase {
1480 SsaPhase::Recovering => {
1481 self.perform_recovered_transition(idx, now, served_total)
1483 }
1484 SsaPhase::AwaitingDeposit => {
1485 self.ssas[idx].recovered_pending = true;
1489 if !self.ssas[idx].next_requested && self.ssas[idx].is_batch_last {
1493 self.ssas[idx].next_request_pending_deposit = true;
1494 self.ssas[idx].next_requested = true;
1495 }
1496 Vec::new()
1497 }
1498 SsaPhase::AwaitingCommitment => {
1499 self.ssas[idx].recovered_pending = true;
1503 Vec::new()
1504 }
1505 _ => Vec::new(),
1507 }
1508 }
1509
1510 fn book_recovered_cycle(&mut self, idx: usize, now: Instant, served_total: u64) {
1516 let target = self.dims.target_useful_shares();
1520 self.ssas[idx].largest_useful_shares = target;
1521 self.ssas[idx].largest_shares_seen = self.ssas[idx].largest_shares_seen.max(target);
1522 self.ssas[idx].recovered_polynomials = self.dims.polys_per_ssa();
1523
1524 self.telemetry.recovered = self.telemetry.recovered.saturating_add(1);
1529 let summary = self.ssas[idx].telemetry_summary(PixCycleOutcome::Recovered, served_total);
1534 self.telemetry.finalized.push(summary);
1535
1536 let phase_sample = self.ssas[idx].enter_phase(
1537 SsaPhase::Recovered {
1538 tombstone_until: now
1539 .checked_add(self.cfg.tombstone_retention_window)
1540 .unwrap_or_else(|| now + Duration::from_secs(86400 * 365)),
1541 },
1542 now,
1543 );
1544 self.telemetry.phase_durations.extend(phase_sample);
1545 }
1546
1547 fn perform_recovered_transition(&mut self, idx: usize, now: Instant, served_total: u64) -> Vec<SessionPixAction> {
1552 let next_requested = self.ssas[idx].next_requested;
1553 let was_front = self.earliest_live_idx() == Some(idx);
1555 let max_shares_seen = self.dims.polys_per_ssa() as u64 * self.dims.emitted_shares_per_poly() as u64;
1556
1557 self.book_recovered_cycle(idx, now, served_total);
1558
1559 if self.draining {
1565 tracing::info!(
1566 ssa_id = %self.ssas[idx].ssa_id,
1567 "the funded cycle of a closed session recovered — the SURB drain is complete"
1568 );
1569 self.closed = true;
1570 return self.with_fill_stopped(vec![SessionPixAction::Close(SessionPixCloseReason::Drained)]);
1571 }
1572
1573 if was_front {
1574 self.paid_recovery_tail =
1578 (self.ssas[idx].largest_shares_seen < max_shares_seen).then(|| PaidRecoveryTail {
1579 ssa_id: self.ssas[idx].ssa_id,
1580 entered_at: now,
1581 largest_shares_seen: self.ssas[idx].largest_shares_seen,
1582 served_total_at_last_progress: served_total,
1583 idle_deadline: now.checked_add(self.cfg.max_recovery_idle),
1588 hard_deadline: self.ssas[idx]
1589 .recovery_hard_deadline
1590 .or_else(|| now.checked_add(self.cfg.max_recovery_time)),
1591 });
1592 }
1593
1594 self.ssas[idx].commitment_deadline = None;
1595 self.ssas[idx].deposit_deadline = None;
1596 self.ssas[idx].recovery_idle_deadline = None;
1597 self.ssas[idx].recovery_hard_deadline = None;
1598 self.ssas[idx].recovered_pending = false;
1599
1600 let mut actions = Vec::new();
1601 if was_front && self.paid_recovery_tail.is_none() {
1607 actions.extend(self.fill.stop().map(SessionPixAction::SetFillRate));
1608 }
1609 if was_front {
1610 actions.push(SessionPixAction::ProgressNotification);
1618 }
1619 if !next_requested && self.ssas[idx].is_batch_last {
1622 self.ssas[idx].next_requested = true;
1623 actions.extend(self.emit_request_next_ssa(now));
1624 }
1625
1626 actions
1627 }
1628
1629 fn on_unverifiable_shares(
1654 &mut self,
1655 ssa_id: &SsaId<HoprPseudonym>,
1656 observed_total: u64,
1657 peer: &OffchainPublicKey,
1658 _now: Instant,
1659 ) -> Vec<SessionPixAction> {
1660 let idx = match self.find_ssa_idx(ssa_id) {
1661 Some(i) => i,
1662 None => return Vec::new(),
1663 };
1664
1665 if self.ssas[idx].is_terminal() {
1669 return Vec::new();
1670 }
1671
1672 tracing::warn!(
1673 %ssa_id,
1674 %peer,
1675 observed_total,
1676 phase = ?self.ssas[idx].phase,
1677 "closing PIX session: a polynomial's share set failed to open its commitment"
1678 );
1679
1680 self.with_fill_stopped(vec![SessionPixAction::Close(SessionPixCloseReason::UnverifiableShares)])
1681 }
1682
1683 fn on_session_closed(&mut self, available: u64, _now: Instant) -> Vec<SessionPixAction> {
1710 let target = self
1711 .paid_recovery_tail
1712 .is_none()
1713 .then(|| self.earliest_live_idx())
1714 .flatten()
1715 .filter(|&idx| {
1716 self.ssas[idx].phase == SsaPhase::Recovering && self.ssas[idx].recovery_hard_deadline.is_some()
1717 });
1718
1719 let cycle_shares = self.dims.polys_per_ssa() as u64 * self.dims.emitted_shares_per_poly() as u64;
1720 let remaining = target.map(|idx| cycle_shares.saturating_sub(self.ssas[idx].largest_shares_seen));
1721
1722 let Some((idx, remaining)) = target.zip(remaining).filter(|&(_, remaining)| available >= remaining) else {
1723 tracing::info!(
1724 available,
1725 ?remaining,
1726 target = ?target.map(|idx| self.ssas[idx].ssa_id),
1727 "closing PIX session: the exit-side close left nothing worth draining for"
1728 );
1729 self.closed = true;
1730 return self.with_fill_stopped(vec![SessionPixAction::Close(SessionPixCloseReason::SessionClosed)]);
1731 };
1732
1733 let target_id = self.ssas[idx].ssa_id;
1734 tracing::info!(
1735 %target_id,
1736 available,
1737 remaining,
1738 "draining buffered SURBs into the funded cycle of a session closed at the exit"
1739 );
1740
1741 self.draining = true;
1742 self.successor_request_deferred = false;
1745
1746 let mut retired = Vec::new();
1750 self.ssas.retain(|ssa| {
1751 if ssa.ssa_id == target_id || ssa.is_terminal() {
1752 true
1753 } else {
1754 retired.push(ssa.ssa_id);
1755 false
1756 }
1757 });
1758
1759 retired
1760 .into_iter()
1761 .map(|ssa_id| {
1762 self.note_retired_index(ssa_id.ssa_index());
1763 SessionPixAction::RetireSsa(ssa_id)
1764 })
1765 .collect()
1766 }
1767
1768 fn close_ssa_and_collect(
1774 &mut self,
1775 idx: usize,
1776 reason: SessionPixCloseReason,
1777 served_total: u64,
1778 now: Instant,
1779 ) -> Vec<SessionPixAction> {
1780 if matches!(self.ssas[idx].phase, SsaPhase::Closing) {
1781 return Vec::new();
1782 }
1783
1784 self.first_failure_reason.get_or_insert(reason);
1786 let close_reason = self.first_failure_reason.unwrap_or(reason);
1787
1788 self.failed_cycles += 1;
1792 self.telemetry.failed = self.telemetry.failed.saturating_add(1);
1797 let summary = self.ssas[idx].telemetry_summary(PixCycleOutcome::Failed, served_total);
1798 self.telemetry.finalized.push(summary);
1799
1800 let ssa = &self.ssas[idx];
1802 tracing::warn!(
1803 ssa_id = %ssa.ssa_id,
1804 ?reason,
1805 phase = ?ssa.phase,
1806 largest_useful_shares = ssa.largest_useful_shares,
1807 target_useful_shares = ssa.target_useful_shares,
1808 recovered_polynomials = ssa.recovered_polynomials,
1809 served_total_at_last_progress = ssa.served_total_at_last_progress,
1810 ?ssa.commitment_deadline,
1811 ?ssa.deposit_deadline,
1812 ?ssa.recovery_idle_deadline,
1813 ?ssa.recovery_hard_deadline,
1814 "closing PIX SSA"
1815 );
1816
1817 let phase_sample = self.ssas[idx].enter_phase(SsaPhase::Closing, now);
1818 self.telemetry.phase_durations.extend(phase_sample);
1819
1820 if self.ssas.len() == 1 {
1821 self.closed = true;
1822 return self.with_fill_stopped(vec![SessionPixAction::Close(close_reason)]);
1823 }
1824
1825 if self.failed_cycles > self.cfg.max_failed_cycles.max(1) {
1833 tracing::warn!(
1834 failed_cycles = self.failed_cycles,
1835 max_failed_cycles = self.cfg.max_failed_cycles,
1836 ?close_reason,
1837 "closing PIX session: too many cycles lost without recovering"
1838 );
1839 self.closed = true;
1840 return self.with_fill_stopped(vec![SessionPixAction::Close(close_reason)]);
1841 }
1842
1843 self.ssas[idx].commitment_deadline = None;
1845 self.ssas[idx].deposit_deadline = None;
1846 self.ssas[idx].recovery_idle_deadline = None;
1847 self.ssas[idx].recovery_hard_deadline = None;
1848
1849 if self
1851 .ssas
1852 .iter()
1853 .all(|s| matches!(s.phase, SsaPhase::Closing | SsaPhase::Recovered { .. }))
1854 {
1855 self.closed = true;
1856 return self.with_fill_stopped(vec![SessionPixAction::Close(close_reason)]);
1857 }
1858
1859 let retired = self.ssas[idx].ssa_id;
1864 self.note_retired_index(retired.ssa_index());
1865 let carried_successor_gate = self.ssas[idx].is_batch_last && !self.ssas[idx].next_requested;
1866 self.ssas.remove(idx);
1867
1868 if carried_successor_gate
1875 && let Some(newest) = self
1876 .ssas
1877 .iter_mut()
1878 .filter(|s| !s.is_terminal())
1879 .max_by_key(|s| s.ssa_id.ssa_index())
1880 {
1881 tracing::debug!(
1882 %retired, promoted = %newest.ssa_id,
1883 "successor gate handed on from a retired cycle"
1884 );
1885 newest.is_batch_last = true;
1886 }
1887 vec![SessionPixAction::RetireSsa(retired)]
1888 }
1889
1890 fn emit_request_next_ssa(&mut self, now: Instant) -> Vec<SessionPixAction> {
1909 if self.draining {
1914 return Vec::new();
1915 }
1916
1917 let batch = self.cfg.ssas_per_request.clamp(1, crate::MAX_SSA_BATCH_SIZE);
1918 let live_cycles = self.live_cycle_count();
1919 let live_batches = self.live_batch_count();
1920 if live_batches >= crate::MAX_OVERLAPPING_BATCHES as usize
1921 || live_cycles.saturating_add(batch) > self.reserved_cycle_slots()
1922 {
1923 self.successor_request_deferred = true;
1924 tracing::debug!(
1925 live_cycles,
1926 live_batches,
1927 requested = batch,
1928 reserved = self.reserved_cycle_slots(),
1929 "deferring successor SSA batch until an older batch is released"
1930 );
1931 return Vec::new();
1932 }
1933
1934 self.successor_request_deferred = false;
1935 let batch_id = self.next_ssa_index;
1936 let mut ssa_ids = Vec::with_capacity(batch);
1937
1938 for _ in 0..batch {
1939 let index = self.next_ssa_index;
1940
1941 let Ok(ssa_index) = SsaIndex::try_from(index) else {
1942 break;
1943 };
1944 let Some(next) = index.checked_add(1) else {
1945 break;
1948 };
1949 self.next_ssa_index = next;
1950
1951 let ssa_id = SsaId::new(self.pseudonym, ssa_index);
1952
1953 self.ssas.push(PerSsaState::new(
1962 ssa_id,
1963 batch_id,
1964 self.dims.target_useful_shares(),
1965 now,
1966 ));
1967 self.telemetry.requested = self.telemetry.requested.saturating_add(1);
1972 ssa_ids.push(ssa_id);
1973 }
1974
1975 if ssa_ids.is_empty() {
1977 self.closed = true;
1978 return vec![SessionPixAction::Close(SessionPixCloseReason::InvalidTransition)];
1979 }
1980
1981 self.ssas
1984 .last_mut()
1985 .expect("a non-empty batch has just been pushed")
1986 .is_batch_last = true;
1987
1988 vec![SessionPixAction::RequestSsa {
1989 ssa_ids,
1990 params: self.dims,
1991 }]
1992 }
1993
1994 fn find_ssa_idx(&self, ssa_id: &SsaId<HoprPseudonym>) -> Option<usize> {
1995 self.ssas.iter().position(|s| s.ssa_id == *ssa_id)
1996 }
1997}
1998
1999impl PerSsaState {
2004 fn check_deadlines(&self, now: Instant) -> Option<SessionPixCloseReason> {
2006 match self.phase {
2007 SsaPhase::AwaitingCommitment => self
2008 .commitment_deadline
2009 .filter(|d| now >= *d)
2010 .map(|_| SessionPixCloseReason::CommitmentTimeout),
2011 SsaPhase::AwaitingDeposit => self
2012 .deposit_deadline
2013 .filter(|d| now >= *d)
2014 .map(|_| SessionPixCloseReason::DepositTimeout),
2015 SsaPhase::Recovering => {
2016 if let Some(d) = self.recovery_hard_deadline
2018 && now >= d
2019 {
2020 return Some(SessionPixCloseReason::RecoveryDeadline);
2021 }
2022 if let Some(d) = self.recovery_idle_deadline
2024 && now >= d
2025 {
2026 return Some(SessionPixCloseReason::RecoveryIdle);
2027 }
2028 None
2029 }
2030 SsaPhase::Recovered { .. } | SsaPhase::Closing => None,
2031 }
2032 }
2033}
2034
2035#[cfg(test)]
2040mod tests {
2041 use std::time::{Duration, Instant};
2042
2043 use hopr_api::{
2044 HoprBalance,
2045 types::{crypto_random::Randomizable, internal::prelude::HoprPseudonym},
2046 };
2047 use hopr_protocol_pix::{SsaId, SsaIndex, SsaRecoveryProgress};
2048
2049 use super::{
2050 super::{FillRate, ORGANIC_WINDOW, PixFillConfig, SAMPLING_INTERVAL},
2051 *,
2052 };
2053
2054 fn default_cfg() -> SupervisorConfig {
2059 SupervisorConfig {
2060 ssas_per_request: 1,
2061 allow_dynamic_ssa_batches: true,
2062 max_failed_cycles: 1,
2063 max_ssa_delivery_time: Duration::from_secs(20),
2064 commitment_recommit_interval: Duration::from_secs(3),
2065 max_deposit_wait: Duration::from_secs(60),
2066 max_recovery_idle: Duration::from_secs(60),
2067 max_recovery_time: Duration::from_secs(3600),
2068 max_off_front_share_fraction: 0.25,
2069 min_share_order_sample: 16384,
2070 max_predeposit_packets: 1024,
2071 max_served_without_progress: 256,
2072 tombstone_retention_window: Duration::from_secs(30),
2073 fill: PixFillConfig::default(),
2074 }
2075 }
2076
2077 fn dims(polys: u16, threshold: u8) -> PixParams {
2083 PixParams::try_new(polys, threshold, 7, crate::types::LOCAL_PIX_SUITE).expect("test dimensions must be valid")
2084 }
2085
2086 fn pseudonym() -> HoprPseudonym {
2087 HoprPseudonym::random()
2088 }
2089
2090 fn ssa_id(p: HoprPseudonym, idx: u32) -> SsaId<HoprPseudonym> {
2091 SsaId::new(p, SsaIndex::new(idx).unwrap())
2092 }
2093
2094 fn make_progress(
2097 ssa_id: SsaId<HoprPseudonym>,
2098 useful: u64,
2099 target: u64,
2100 recovered_polys: u16,
2101 ) -> SsaRecoveryProgress<HoprPseudonym> {
2102 make_progress_seen(ssa_id, useful, useful, target, recovered_polys)
2103 }
2104
2105 fn make_progress_seen(
2107 ssa_id: SsaId<HoprPseudonym>,
2108 useful: u64,
2109 seen: u64,
2110 target: u64,
2111 recovered_polys: u16,
2112 ) -> SsaRecoveryProgress<HoprPseudonym> {
2113 SsaRecoveryProgress {
2114 ssa_id,
2115 useful_shares: useful,
2116 shares_seen: seen,
2117 target_useful_shares: target,
2118 recovered_polynomials: recovered_polys,
2119 }
2120 }
2121
2122 fn make_recovered_tail_progress(
2123 sup: &SessionPixSupervisor,
2124 ssa_id: SsaId<HoprPseudonym>,
2125 shares_seen: u64,
2126 ) -> SsaRecoveryProgress<HoprPseudonym> {
2127 SsaRecoveryProgress {
2128 ssa_id,
2129 useful_shares: sup.dims.target_useful_shares(),
2130 shares_seen,
2131 target_useful_shares: sup.dims.target_useful_shares(),
2132 recovered_polynomials: sup.dims.polys_per_ssa(),
2133 }
2134 }
2135
2136 fn max_cycle_shares(sup: &SessionPixSupervisor) -> u64 {
2137 sup.dims.polys_per_ssa() as u64 * sup.dims.emitted_shares_per_poly() as u64
2138 }
2139
2140 fn sufficient_balance() -> HoprBalance {
2141 HoprBalance::new_base(1000)
2142 }
2143
2144 fn fund_batch(sup: &mut SessionPixSupervisor, p: HoprPseudonym, batch: u32, now: Instant) {
2146 for idx in 1..=batch {
2147 let id = ssa_id(p, idx);
2148 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2149 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
2150 sup.handle_event(
2151 &SessionPixEvent::DepositConfirmed {
2152 ssa_id: id,
2153 amount: sufficient_balance(),
2154 },
2155 now,
2156 0,
2157 );
2158 }
2159 }
2160
2161 fn commit_unfunded(
2166 sup: &mut SessionPixSupervisor,
2167 p: HoprPseudonym,
2168 indices: std::ops::RangeInclusive<u32>,
2169 now: Instant,
2170 ) {
2171 for idx in indices {
2172 let id = ssa_id(p, idx);
2173 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2174 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
2175 }
2176 }
2177
2178 fn _small_balance() -> HoprBalance {
2179 HoprBalance::new_base(1)
2180 }
2181
2182 #[test]
2184 fn a_cycle_is_timed_in_each_phase_it_leaves() {
2185 let p = pseudonym();
2186 let t0 = Instant::now();
2187 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, t0);
2188 let id = ssa_id(p, 1);
2189
2190 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), t0, 0);
2191 assert!(
2192 sup.take_telemetry_events().phase_durations.is_empty(),
2193 "a cycle that has not left a phase has nothing to time"
2194 );
2195
2196 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), t0 + Duration::from_secs(3), 0);
2198 assert_eq!(
2199 vec![(PixCyclePhase::AwaitingCommitment, 3_000)],
2200 sup.take_telemetry_events().phase_durations
2201 );
2202
2203 sup.handle_event(
2204 &SessionPixEvent::DepositConfirmed {
2205 ssa_id: id,
2206 amount: sufficient_balance(),
2207 },
2208 t0 + Duration::from_secs(10),
2209 0,
2210 );
2211 assert_eq!(
2212 vec![(PixCyclePhase::AwaitingDeposit, 7_000)],
2213 sup.take_telemetry_events().phase_durations,
2214 "the clock restarts at each transition rather than measuring from the request"
2215 );
2216
2217 sup.handle_event(&SessionPixEvent::Recovered(id), t0 + Duration::from_secs(70), 0);
2220 assert_eq!(
2221 vec![(PixCyclePhase::Recovering, 60_000)],
2222 sup.take_telemetry_events().phase_durations
2223 );
2224 }
2225
2226 #[test]
2231 fn new_emits_initial_request_for_index_one() {
2232 let p = pseudonym();
2233 let (sup, actions) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
2234
2235 assert_eq!(actions.len(), 1);
2236 match &actions[0] {
2237 SessionPixAction::RequestSsa { ssa_ids, params } => {
2238 assert_eq!(*params, dims(10, 5));
2239 assert_eq!(ssa_ids.len(), 1, "the default batch is a single SSA");
2240 assert_eq!(ssa_ids[0].ssa_index(), SsaIndex::new(1).unwrap());
2241 }
2242 other => panic!("expected RequestSsa, got {other:?}"),
2243 }
2244
2245 assert_eq!(sup.next_ssa_index, 2);
2246 assert!(!sup.closed);
2247
2248 assert_eq!(sup.ssas.len(), 1);
2251 assert_eq!(sup.ssas[0].phase, SsaPhase::AwaitingCommitment);
2252 assert!(
2253 sup.ssas[0].commitment_deadline.is_none(),
2254 "the delivery clock starts when the request goes out, not when it is queued"
2255 );
2256 }
2257
2258 #[test]
2264 fn a_batch_is_allocated_and_requested_as_one_action() {
2265 const BATCH: usize = 4;
2266
2267 let p = pseudonym();
2268 let cfg = SupervisorConfig {
2269 ssas_per_request: BATCH,
2270 ..default_cfg()
2271 };
2272 let (sup, actions) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
2273
2274 assert_eq!(actions.len(), 1, "the batch must travel as a single action");
2275 match &actions[0] {
2276 SessionPixAction::RequestSsa { ssa_ids, .. } => {
2277 assert_eq!(
2278 ssa_ids.iter().map(|i| i.ssa_index().get()).collect::<Vec<_>>(),
2279 (1..=BATCH as u32).collect::<Vec<_>>(),
2280 "the batch must cover contiguous indices from 1"
2281 );
2282 }
2283 other => panic!("expected RequestSsa, got {other:?}"),
2284 }
2285
2286 assert_eq!(
2287 sup.next_ssa_index,
2288 BATCH as u32 + 1,
2289 "the index counter must advance past the whole batch"
2290 );
2291 assert_eq!(
2292 sup.ssas.len(),
2293 BATCH,
2294 "every SSA in the batch needs its own record, or observations about it fail open"
2295 );
2296 assert!(sup.ssas.iter().all(|s| s.phase == SsaPhase::AwaitingCommitment));
2297 assert!(!sup.closed);
2298 }
2299
2300 #[test]
2307 fn batching_scales_both_per_cycle_deadlines() {
2308 const BATCH: usize = 3;
2309
2310 let p = pseudonym();
2311 let unit_commit = default_cfg().max_ssa_delivery_time;
2312 let unit_deposit = default_cfg().max_deposit_wait;
2313 let cfg = SupervisorConfig {
2314 ssas_per_request: BATCH,
2315 ..default_cfg()
2316 };
2317 let now = Instant::now();
2318 let (mut sup, actions) = SessionPixSupervisor::new(cfg, dims(10, 5), p, now);
2319 let SessionPixAction::RequestSsa { ssa_ids, .. } = &actions[0] else {
2320 panic!("expected RequestSsa");
2321 };
2322 let ssa_ids = ssa_ids.clone();
2323
2324 for id in &ssa_ids {
2326 sup.handle_event(&SessionPixEvent::SsaRequestSent(*id), now, 0);
2327 }
2328 for (i, id) in ssa_ids.iter().enumerate() {
2329 let idx = sup.find_ssa_idx(id).expect("record must exist");
2330 assert_eq!(
2331 sup.ssas[idx].commitment_deadline,
2332 Some(now + BATCH as u32 * unit_commit),
2333 "cycle {i} must get the batch-scaled commitment deadline"
2334 );
2335 }
2336
2337 let later = now + Duration::from_secs(1);
2339 sup.handle_event(&SessionPixEvent::CommitmentVerified(ssa_ids[0]), later, 0);
2340 let idx = sup.find_ssa_idx(&ssa_ids[0]).expect("record must exist");
2341 assert_eq!(sup.ssas[idx].phase, SsaPhase::AwaitingDeposit);
2342 assert_eq!(
2343 sup.ssas[idx].deposit_deadline,
2344 Some(later + BATCH as u32 * unit_deposit),
2345 "the deposit deadline must be batch-scaled too"
2346 );
2347 }
2348
2349 #[test]
2355 fn an_unvalidated_batch_size_is_clamped() {
2356 for (configured, expected) in [
2357 (0usize, 1usize),
2358 (crate::MAX_SSA_BATCH_SIZE + 5, crate::MAX_SSA_BATCH_SIZE),
2359 ] {
2360 let p = pseudonym();
2361 let cfg = SupervisorConfig {
2362 ssas_per_request: configured,
2363 ..default_cfg()
2364 };
2365 let (sup, actions) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
2366 match &actions[0] {
2367 SessionPixAction::RequestSsa { ssa_ids, .. } => assert_eq!(
2368 ssa_ids.len(),
2369 expected,
2370 "a configured batch of {configured} must be clamped to {expected}"
2371 ),
2372 other => panic!("expected RequestSsa, got {other:?}"),
2373 }
2374 assert_eq!(sup.ssas.len(), expected);
2375 }
2376 }
2377
2378 #[test]
2383 fn request_sent_starts_commitment_deadline() {
2384 let p = pseudonym();
2385 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
2386 let now = Instant::now();
2387 let id = ssa_id(p, 1);
2388
2389 let actions = sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2390 assert!(actions.is_empty());
2391
2392 let ssa = sup.ssas.iter().find(|s| s.ssa_id == id).unwrap();
2393 assert_eq!(ssa.phase, SsaPhase::AwaitingCommitment);
2394 assert!(ssa.commitment_deadline.is_some());
2395 }
2396
2397 #[test]
2398 fn request_sent_is_idempotent() {
2399 let p = pseudonym();
2400 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
2401 let now = Instant::now();
2402 let id = ssa_id(p, 1);
2403
2404 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2405 let actions = sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2406 assert!(actions.is_empty());
2407 assert_eq!(sup.ssas.len(), 1);
2408 }
2409
2410 #[test]
2411 fn request_sent_wrong_pseudonym_closes() {
2412 let p = pseudonym();
2413 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
2414 let wrong_p = pseudonym();
2415 let now = Instant::now();
2416
2417 let actions = sup.handle_event(&SessionPixEvent::SsaRequestSent(ssa_id(wrong_p, 1)), now, 0);
2418 assert_eq!(actions.len(), 1);
2419 assert!(matches!(actions[0], SessionPixAction::Close(_)));
2420 }
2421
2422 #[test]
2427 fn commitment_verified_starts_the_deposit_deadline() {
2428 let p = pseudonym();
2429 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
2430 let now = Instant::now();
2431 let id = ssa_id(p, 1);
2432
2433 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2434
2435 let actions = sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
2436 assert!(actions.is_empty());
2437
2438 let ssa = sup.ssas.iter().find(|s| s.ssa_id == id).unwrap();
2439 assert_eq!(ssa.phase, SsaPhase::AwaitingDeposit);
2440 assert!(ssa.deposit_deadline.is_some());
2441 assert!(ssa.commitment_deadline.is_none());
2442 }
2443
2444 #[test]
2445 fn commitment_verified_is_idempotent() {
2446 let p = pseudonym();
2447 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
2448 let now = Instant::now();
2449 let id = ssa_id(p, 1);
2450
2451 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2452 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
2453 let actions = sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
2454 assert!(actions.is_empty());
2455 assert_eq!(
2456 sup.ssas.iter().find(|s| s.ssa_id == id).unwrap().phase,
2457 SsaPhase::AwaitingDeposit
2458 );
2459 }
2460
2461 #[test]
2466 fn sufficient_deposit_enters_recovering_starts_idle_and_hard() {
2467 let p = pseudonym();
2468 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
2469 let now = Instant::now();
2470 let id = ssa_id(p, 1);
2471
2472 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2473 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
2474
2475 let actions = sup.handle_event(
2476 &SessionPixEvent::DepositConfirmed {
2477 ssa_id: id,
2478 amount: sufficient_balance(),
2479 },
2480 now,
2481 0,
2482 );
2483
2484 assert_eq!(actions.len(), 1);
2485 assert!(matches!(actions[0], SessionPixAction::ReleaseService));
2486
2487 let ssa = sup.ssas.iter().find(|s| s.ssa_id == id).unwrap();
2488 assert_eq!(ssa.phase, SsaPhase::Recovering);
2489 assert!(ssa.recovery_idle_deadline.is_some());
2490 assert!(ssa.recovery_hard_deadline.is_some());
2491 assert!(ssa.deposit_deadline.is_none());
2492 }
2493
2494 #[test]
2495 fn first_funding_emits_release_service_once() {
2496 let p = pseudonym();
2497 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
2498 let now = Instant::now();
2499 let id1 = ssa_id(p, 1);
2500
2501 sup.handle_event(&SessionPixEvent::SsaRequestSent(id1), now, 0);
2502 sup.handle_event(&SessionPixEvent::CommitmentVerified(id1), now, 0);
2503
2504 let actions = sup.handle_event(
2505 &SessionPixEvent::DepositConfirmed {
2506 ssa_id: id1,
2507 amount: sufficient_balance(),
2508 },
2509 now,
2510 0,
2511 );
2512 assert_eq!(actions.len(), 1);
2513 assert!(matches!(actions[0], SessionPixAction::ReleaseService));
2514
2515 sup.handle_event(&SessionPixEvent::SsaRequestSent(ssa_id(p, 2)), now, 0);
2517 sup.handle_event(&SessionPixEvent::CommitmentVerified(ssa_id(p, 2)), now, 0);
2518 let actions = sup.handle_event(
2519 &SessionPixEvent::DepositConfirmed {
2520 ssa_id: ssa_id(p, 2),
2521 amount: sufficient_balance(),
2522 },
2523 now,
2524 0,
2525 );
2526 assert!(actions.iter().all(|a| !matches!(a, SessionPixAction::ReleaseService)));
2527 }
2528
2529 #[test]
2530 fn a_funded_successor_gets_a_fresh_service_ceiling_when_it_reaches_the_front() {
2531 let mut cfg = default_cfg();
2532 cfg.ssas_per_request = 2;
2533 cfg.max_served_without_progress = 2;
2534 let p = pseudonym();
2535 let now = Instant::now();
2536 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, now);
2537
2538 fund_batch(&mut sup, p, 2, now);
2539 let actions = sup.handle_event(&SessionPixEvent::Recovered(ssa_id(p, 1)), now, 0);
2540 assert!(
2541 matches!(actions.as_slice(), [SessionPixAction::ProgressNotification]),
2542 "recovery resets the existing gate but is not the FIFO boundary: {actions:?}"
2543 );
2544
2545 let successor = sup.ssas.iter().find(|ssa| ssa.ssa_id == ssa_id(p, 2)).unwrap();
2546 assert_eq!(
2547 (successor.recovery_idle_deadline, successor.recovery_hard_deadline),
2548 (None, None),
2549 "successor clocks must not run while every served SURB still belongs to its predecessor"
2550 );
2551
2552 let target = sup.dims.target_useful_shares();
2553 let max_seen = max_cycle_shares(&sup);
2554 for (offset, seen) in ((target + 1)..=max_seen).enumerate() {
2555 let at = now + Duration::from_secs(offset as u64 + 1);
2556 let actions = sup.handle_event(
2557 &SessionPixEvent::RecoveryProgress(make_recovered_tail_progress(&sup, ssa_id(p, 1), seen)),
2558 at,
2559 offset as u64 + 1,
2560 );
2561 assert!(
2562 actions
2563 .iter()
2564 .any(|action| matches!(action, SessionPixAction::ProgressNotification)),
2565 "buffered paid share {seen} must refresh liveness: {actions:?}"
2566 );
2567 assert!(
2568 actions
2569 .iter()
2570 .all(|action| !matches!(action, SessionPixAction::Close(_))),
2571 "a tail longer than the gate ceiling must not close an honest Session: {actions:?}"
2572 );
2573 if seen == max_seen {
2574 assert!(
2575 actions
2576 .iter()
2577 .any(|action| matches!(action, SessionPixAction::ReleaseService)),
2578 "the funded successor must receive a fresh service ceiling at the exact FIFO boundary: {actions:?}"
2579 );
2580 }
2581 }
2582
2583 assert!(
2584 sup.paid_recovery_tail.is_none(),
2585 "the exact negotiated tail bound must finish the handoff"
2586 );
2587 let successor = sup.ssas.iter().find(|ssa| ssa.ssa_id == ssa_id(p, 2)).unwrap();
2588 assert!(
2589 successor.recovery_idle_deadline.is_some() && successor.recovery_hard_deadline.is_some(),
2590 "successor clocks start only after the final predecessor-bound SURB"
2591 );
2592 }
2593
2594 #[test]
2601 fn any_nonzero_deposit_funds_the_cycle() {
2602 for amount in [HoprBalance::new_base(1), sufficient_balance()] {
2603 let p = pseudonym();
2604 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
2605 let now = Instant::now();
2606 let id = ssa_id(p, 1);
2607
2608 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2609 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
2610
2611 let actions = sup.handle_event(&SessionPixEvent::DepositConfirmed { ssa_id: id, amount }, now, 0);
2612
2613 assert_eq!(
2614 sup.ssas.iter().find(|s| s.ssa_id == id).unwrap().phase,
2615 SsaPhase::Recovering,
2616 "a confirmation of {amount} is the pool's verdict and must fund the cycle"
2617 );
2618 assert!(!actions.is_empty());
2619 }
2620 }
2621
2622 #[test]
2628 fn a_zero_deposit_is_not_a_verdict_and_leaves_the_cycle_fundable() {
2629 let p = pseudonym();
2630 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
2631 let now = Instant::now();
2632 let id = ssa_id(p, 1);
2633
2634 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2635 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
2636
2637 let deadline_before = sup.ssas.iter().find(|s| s.ssa_id == id).unwrap().deposit_deadline;
2638
2639 let actions = sup.handle_event(
2640 &SessionPixEvent::DepositConfirmed {
2641 ssa_id: id,
2642 amount: HoprBalance::zero(),
2643 },
2644 now,
2645 0,
2646 );
2647 assert!(actions.is_empty());
2648
2649 let ssa = sup.ssas.iter().find(|s| s.ssa_id == id).unwrap();
2650 assert_eq!(ssa.phase, SsaPhase::AwaitingDeposit);
2651 assert_eq!(
2652 ssa.deposit_deadline, deadline_before,
2653 "a zero confirmation must not extend or clear the deadline"
2654 );
2655
2656 let actions = sup.handle_event(
2658 &SessionPixEvent::DepositConfirmed {
2659 ssa_id: id,
2660 amount: sufficient_balance(),
2661 },
2662 now,
2663 0,
2664 );
2665 assert_eq!(
2666 sup.ssas.iter().find(|s| s.ssa_id == id).unwrap().phase,
2667 SsaPhase::Recovering
2668 );
2669 assert!(!actions.is_empty());
2670 }
2671
2672 #[test]
2677 fn funded_successor_releases_service_when_unfunded_front_is_retired() {
2678 let mut cfg = default_cfg();
2679 cfg.ssas_per_request = 2;
2680 let p = pseudonym();
2681 let now = Instant::now();
2682 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, now);
2683 let front = ssa_id(p, 1);
2684 let successor = ssa_id(p, 2);
2685
2686 for id in [front, successor] {
2687 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2688 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
2689 }
2690
2691 let actions = sup.handle_event(
2692 &SessionPixEvent::DepositConfirmed {
2693 ssa_id: successor,
2694 amount: sufficient_balance(),
2695 },
2696 now,
2697 0,
2698 );
2699 assert!(
2700 actions.iter().all(|a| !matches!(a, SessionPixAction::ReleaseService)),
2701 "funding a cycle behind an unfunded front must not release service"
2702 );
2703
2704 let actions = sup.handle_event(&SessionPixEvent::DepositObserverClosed(front), now, 0);
2705 assert!(
2706 actions.iter().any(|a| matches!(a, SessionPixAction::ReleaseService)),
2707 "retiring the unfunded front must release service for its already-funded successor; got {actions:?}"
2708 );
2709 }
2710
2711 #[test]
2719 fn a_cycle_that_funds_and_recovers_at_once_rearms_for_its_unfunded_successor() {
2720 let mut cfg = default_cfg();
2721 cfg.ssas_per_request = 2;
2722 let p = pseudonym();
2723 let now = Instant::now();
2724 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, now);
2725 let front = ssa_id(p, 1);
2726 let successor = ssa_id(p, 2);
2727
2728 for id in [front, successor] {
2729 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2730 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
2731 }
2732
2733 let actions = sup.handle_event(&SessionPixEvent::Recovered(front), now, 0);
2735 assert!(
2736 actions.iter().all(|a| !matches!(a, SessionPixAction::ReleaseService)),
2737 "recovering an unfunded cycle must not release service"
2738 );
2739
2740 let actions = sup.handle_event(
2743 &SessionPixEvent::DepositConfirmed {
2744 ssa_id: front,
2745 amount: sufficient_balance(),
2746 },
2747 now,
2748 0,
2749 );
2750 assert!(
2751 matches!(
2752 actions.as_slice(),
2753 [SessionPixAction::ReleaseService, SessionPixAction::ProgressNotification]
2754 ),
2755 "the paid handoff must open only the bounded predecessor tail, got {actions:?}"
2756 );
2757
2758 let max_seen = max_cycle_shares(&sup);
2759 let actions = sup.handle_event(
2760 &SessionPixEvent::RecoveryProgress(make_recovered_tail_progress(&sup, front, max_seen)),
2761 now,
2762 max_seen,
2763 );
2764 assert!(
2765 actions
2766 .iter()
2767 .any(|action| matches!(action, SessionPixAction::WithholdService)),
2768 "exhausting the paid tail must restore the unfunded successor's allowance: {actions:?}"
2769 );
2770
2771 let actions = sup.handle_event(
2772 &SessionPixEvent::DepositConfirmed {
2773 ssa_id: successor,
2774 amount: sufficient_balance(),
2775 },
2776 now,
2777 0,
2778 );
2779 assert!(
2780 actions.iter().any(|a| matches!(a, SessionPixAction::ReleaseService)),
2781 "the successor's own deposit must open service, got {actions:?}"
2782 );
2783 }
2784
2785 #[test]
2786 fn progress_on_an_unfunded_front_does_not_notify_the_service_gate() {
2787 let mut cfg = default_cfg();
2788 cfg.ssas_per_request = 2;
2789 cfg.max_off_front_share_fraction = 1.0;
2790 let p = pseudonym();
2791 let now = Instant::now();
2792 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, now);
2793 let funded_front = ssa_id(p, 1);
2794 let unfunded_successor = ssa_id(p, 2);
2795
2796 for id in [funded_front, unfunded_successor] {
2797 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2798 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
2799 }
2800 sup.handle_event(
2801 &SessionPixEvent::DepositConfirmed {
2802 ssa_id: funded_front,
2803 amount: sufficient_balance(),
2804 },
2805 now,
2806 0,
2807 );
2808 sup.handle_event(&SessionPixEvent::Recovered(funded_front), now, 0);
2809
2810 let actions = sup.handle_event(
2811 &SessionPixEvent::RecoveryProgress(make_progress(
2812 unfunded_successor,
2813 1,
2814 sup.dims.target_useful_shares(),
2815 0,
2816 )),
2817 now,
2818 1,
2819 );
2820
2821 assert!(
2822 actions
2823 .iter()
2824 .all(|action| !matches!(action, SessionPixAction::ProgressNotification)),
2825 "an unfunded front must not reopen the funded service ceiling, got {actions:?}"
2826 );
2827 assert!(
2828 actions
2829 .iter()
2830 .any(|action| matches!(action, SessionPixAction::WithholdService)),
2831 "the first successor-bound packet must close the predecessor's funded gate: {actions:?}"
2832 );
2833 assert!(
2834 sup.paid_recovery_tail.is_none(),
2835 "an unfunded successor must not trick the Exit into spending its predecessor's remaining allowance"
2836 );
2837 }
2838
2839 #[test]
2840 fn progress_behind_the_front_does_not_notify_the_service_gate() {
2841 let mut cfg = default_cfg();
2842 cfg.ssas_per_request = 2;
2843 cfg.max_off_front_share_fraction = 1.0;
2844 let p = pseudonym();
2845 let now = Instant::now();
2846 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, now);
2847
2848 fund_batch(&mut sup, p, 2, now);
2849 let actions = sup.handle_event(
2850 &SessionPixEvent::RecoveryProgress(make_progress(ssa_id(p, 2), 1, sup.dims.target_useful_shares(), 0)),
2851 now,
2852 1,
2853 );
2854
2855 assert!(
2856 actions.is_empty(),
2857 "off-front progress must not reopen the gate, got {actions:?}"
2858 );
2859 }
2860
2861 #[test]
2869 fn a_live_cycle_below_the_retired_watermark_still_arms_its_deadline() {
2870 let mut cfg = default_cfg();
2871 cfg.ssas_per_request = 2;
2872 let p = pseudonym();
2873 let now = Instant::now();
2874 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, now);
2875 let earlier = ssa_id(p, 1);
2876 let later = ssa_id(p, 2);
2877
2878 sup.handle_event(&SessionPixEvent::SsaRequestSent(later), now, 0);
2880 sup.handle_event(&SessionPixEvent::CommitmentVerified(later), now, 0);
2881 sup.handle_event(&SessionPixEvent::DepositObserverClosed(later), now, 0);
2882 assert_eq!(
2883 Some(later.ssa_index()),
2884 sup.highest_retired_ssa_index,
2885 "the later member must have set the watermark"
2886 );
2887
2888 sup.handle_event(&SessionPixEvent::SsaRequestSent(earlier), now, 0);
2890 let armed = sup
2891 .ssas
2892 .iter()
2893 .find(|s| s.ssa_id == earlier)
2894 .expect("the earlier cycle must still be live")
2895 .commitment_deadline;
2896 assert!(
2897 armed.is_some(),
2898 "a live cycle below the watermark must still have its commitment deadline armed"
2899 );
2900 }
2901
2902 #[test]
2908 fn a_request_event_for_a_retired_index_is_ignored() {
2909 let mut cfg = default_cfg();
2910 cfg.ssas_per_request = 2;
2911 let p = pseudonym();
2912 let now = Instant::now();
2913 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, now);
2914 let retired = ssa_id(p, 1);
2915
2916 sup.handle_event(&SessionPixEvent::SsaRequestSent(retired), now, 0);
2917 sup.handle_event(&SessionPixEvent::CommitmentVerified(retired), now, 0);
2918 sup.handle_event(&SessionPixEvent::DepositObserverClosed(retired), now, 0);
2919 assert!(!sup.closed, "the second batch member must keep the Session alive");
2920
2921 let actions = sup.handle_event(&SessionPixEvent::SsaRequestSent(retired), now, 0);
2922 assert!(
2923 actions.iter().all(|a| !matches!(a, SessionPixAction::Close(_))),
2924 "a straggling request for a retired index must be ignored, not fatal; got {actions:?}"
2925 );
2926
2927 let actions = sup.handle_event(&SessionPixEvent::SsaRequestSent(ssa_id(p, 9)), now, 0);
2930 assert!(
2931 actions
2932 .iter()
2933 .any(|a| matches!(a, SessionPixAction::Close(SessionPixCloseReason::InvalidTransition))),
2934 "an index above the watermark with no record is a protocol fault; got {actions:?}"
2935 );
2936 }
2937
2938 #[test]
2939 fn duplicate_deposit_confirmation_is_idempotent() {
2940 let p = pseudonym();
2941 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
2942 let now = Instant::now();
2943 let id = ssa_id(p, 1);
2944
2945 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
2946 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
2947
2948 sup.handle_event(
2949 &SessionPixEvent::DepositConfirmed {
2950 ssa_id: id,
2951 amount: sufficient_balance(),
2952 },
2953 now,
2954 0,
2955 );
2956
2957 let actions = sup.handle_event(
2958 &SessionPixEvent::DepositConfirmed {
2959 ssa_id: id,
2960 amount: sufficient_balance(),
2961 },
2962 now,
2963 0,
2964 );
2965 assert!(actions.is_empty());
2966 }
2967
2968 #[test]
2969 fn wrong_ssa_deposit_does_not_transition_any_ssa() {
2970 let p = pseudonym();
2971 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
2972 let now = Instant::now();
2973
2974 sup.handle_event(&SessionPixEvent::SsaRequestSent(ssa_id(p, 1)), now, 0);
2975
2976 let actions = sup.handle_event(
2977 &SessionPixEvent::DepositConfirmed {
2978 ssa_id: ssa_id(p, 99),
2979 amount: sufficient_balance(),
2980 },
2981 now,
2982 0,
2983 );
2984 assert!(actions.is_empty());
2985 }
2986
2987 #[test]
2992 fn commitment_deadline_expiry_closes_awaiting_commitment() {
2993 let p = pseudonym();
2994 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
2995 let start = Instant::now();
2996 let id = ssa_id(p, 1);
2997
2998 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
2999
3000 let actions = sup.handle_deadline(start + Duration::from_secs(10), 0);
3001 assert!(actions.is_empty());
3002
3003 let actions = sup.handle_deadline(start + Duration::from_secs(21), 0);
3004 assert_eq!(actions.len(), 1);
3005 assert!(matches!(
3006 actions[0],
3007 SessionPixAction::Close(SessionPixCloseReason::CommitmentTimeout)
3008 ));
3009 }
3010
3011 #[test]
3014 fn a_stalled_commitment_is_re_requested_until_the_delivery_deadline() {
3015 let p = pseudonym();
3016 let cfg = default_cfg();
3017 let (mut sup, _) = SessionPixSupervisor::new(cfg.clone(), dims(10, 5), p, Instant::now());
3018 let start = Instant::now();
3019 let id = ssa_id(p, 1);
3020
3021 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
3022
3023 assert!(
3025 sup.handle_deadline(start + cfg.commitment_recommit_interval * 3, 0)
3026 .is_empty(),
3027 "a commitment nothing was delivered for must not be re-requested"
3028 );
3029
3030 let first_part = start + Duration::from_secs(1);
3032 sup.handle_event(&SessionPixEvent::CommitmentProgress(id), first_part, 0);
3033 assert!(
3034 sup.handle_deadline(first_part + cfg.commitment_recommit_interval / 2, 0)
3035 .is_empty(),
3036 "the interval must be measured from the last part that arrived"
3037 );
3038
3039 let last_part = first_part + cfg.commitment_recommit_interval;
3042 sup.handle_event(&SessionPixEvent::CommitmentProgress(id), last_part, 0);
3043 assert!(
3044 sup.handle_deadline(last_part + cfg.commitment_recommit_interval / 2, 0)
3045 .is_empty(),
3046 "an arriving part must re-arm the timer"
3047 );
3048
3049 let delivery_deadline =
3053 start + crate::supervision::scaled_deadline(cfg.max_ssa_delivery_time, cfg.ssas_per_request);
3054 let mut now = last_part;
3055 let mut asks = 0;
3056 while now + cfg.commitment_recommit_interval < delivery_deadline {
3057 now += cfg.commitment_recommit_interval;
3058 let actions = sup.handle_deadline(now, 0);
3059 assert!(
3060 matches!(
3061 actions.as_slice(),
3062 [SessionPixAction::RequestCommitmentRetransmission { ssa_id, params }]
3063 if *ssa_id == id && *params == dims(10, 5)
3064 ),
3065 "ask {} must name the missing parts, got {actions:?}",
3066 asks + 1
3067 );
3068 asks += 1;
3069 }
3070 assert!(asks > 1, "the interval must fit several asks inside the deadline");
3071
3072 let actions = sup.handle_deadline(delivery_deadline, 0);
3075 assert!(matches!(
3076 actions.as_slice(),
3077 [SessionPixAction::Close(SessionPixCloseReason::CommitmentTimeout)]
3078 ));
3079 }
3080
3081 #[test]
3087 fn asks_for_a_stalled_commitment_are_capped_by_count() {
3088 let p = pseudonym();
3089 let cfg = SupervisorConfig {
3092 commitment_recommit_interval: Duration::from_secs(1),
3093 ..default_cfg()
3094 };
3095 let (mut sup, _) = SessionPixSupervisor::new(cfg.clone(), dims(10, 5), p, Instant::now());
3096 let start = Instant::now();
3097 let id = ssa_id(p, 1);
3098
3099 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
3100 sup.handle_event(&SessionPixEvent::CommitmentProgress(id), start, 0);
3101
3102 let mut now = start;
3103 for attempt in 1..=hopr_protocol_pix::MAX_COMMITMENT_RETRANSMISSIONS {
3104 now += cfg.commitment_recommit_interval;
3105 let actions = sup.handle_deadline(now, 0);
3106 assert!(
3107 matches!(
3108 actions.as_slice(),
3109 [SessionPixAction::RequestCommitmentRetransmission { ssa_id, .. }] if *ssa_id == id
3110 ),
3111 "attempt {attempt} must ask for the missing parts, got {actions:?}"
3112 );
3113 }
3114
3115 now += cfg.commitment_recommit_interval;
3116 assert!(
3117 sup.handle_deadline(now, 0).is_empty(),
3118 "the ask budget must be spent, with the delivery deadline still far off"
3119 );
3120 assert_eq!(
3121 Some(start + crate::supervision::scaled_deadline(cfg.max_ssa_delivery_time, cfg.ssas_per_request)),
3122 sup.next_deadline(),
3123 "a spent budget must leave no timer for the worker to wake on"
3124 );
3125
3126 sup.handle_event(&SessionPixEvent::CommitmentProgress(id), now, 0);
3128 assert!(
3129 sup.handle_deadline(now + cfg.commitment_recommit_interval * 2, 0)
3130 .is_empty(),
3131 "an arriving part must not re-open a spent budget"
3132 );
3133 }
3134
3135 #[test]
3138 fn the_re_request_timer_leads_the_deadlines_and_ends_with_the_commitment() {
3139 let p = pseudonym();
3140 let cfg = default_cfg();
3141 let (mut sup, _) = SessionPixSupervisor::new(cfg.clone(), dims(10, 5), p, Instant::now());
3142 let start = Instant::now();
3143 let id = ssa_id(p, 1);
3144
3145 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
3146 sup.handle_event(&SessionPixEvent::CommitmentProgress(id), start, 0);
3147 assert_eq!(
3148 Some(start + cfg.commitment_recommit_interval),
3149 sup.next_deadline(),
3150 "the worker must wake for the re-request before the delivery deadline"
3151 );
3152
3153 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
3154 assert_eq!(
3155 Some(start + crate::supervision::scaled_deadline(cfg.max_deposit_wait, cfg.ssas_per_request)),
3156 sup.next_deadline(),
3157 "a completed commitment leaves only the deposit clock"
3158 );
3159 assert!(
3160 sup.handle_deadline(start + cfg.commitment_recommit_interval, 0)
3161 .is_empty(),
3162 "nothing may be re-requested once the commitment verified"
3163 );
3164
3165 sup.handle_event(
3168 &SessionPixEvent::CommitmentProgress(id),
3169 start + cfg.commitment_recommit_interval,
3170 0,
3171 );
3172 assert!(
3173 sup.handle_deadline(start + cfg.commitment_recommit_interval * 3, 0)
3174 .is_empty(),
3175 "a late part must not re-arm a timer the phase has left"
3176 );
3177 }
3178
3179 #[test]
3180 fn deposit_deadline_expiry_closes_awaiting_deposit() {
3181 let p = pseudonym();
3182 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
3183 let start = Instant::now();
3184 let id = ssa_id(p, 1);
3185
3186 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
3187 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
3188
3189 let actions = sup.handle_deadline(start + Duration::from_secs(61), 0);
3190 assert_eq!(actions.len(), 1);
3191 assert!(matches!(
3192 actions[0],
3193 SessionPixAction::Close(SessionPixCloseReason::DepositTimeout)
3194 ));
3195 }
3196
3197 #[test]
3202 fn deposit_observer_closed_closes_with_distinct_reason() {
3203 let p = pseudonym();
3204 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
3205 let now = Instant::now();
3206 let id = ssa_id(p, 1);
3207
3208 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
3209 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
3210
3211 let actions = sup.handle_event(&SessionPixEvent::DepositObserverClosed(id), now, 0);
3212 assert_eq!(actions.len(), 1);
3213 assert!(matches!(
3214 actions[0],
3215 SessionPixAction::Close(SessionPixCloseReason::DepositObserverClosed)
3216 ));
3217 }
3218
3219 #[test]
3224 fn stale_timer_wake_after_transition_does_not_close() {
3225 let p = pseudonym();
3226 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
3227 let start = Instant::now();
3228 let id = ssa_id(p, 1);
3229
3230 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
3231 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
3232
3233 sup.handle_event(
3235 &SessionPixEvent::DepositConfirmed {
3236 ssa_id: id,
3237 amount: sufficient_balance(),
3238 },
3239 start,
3240 0,
3241 );
3242
3243 let actions = sup.handle_deadline(start + Duration::from_secs(120), 0);
3244 assert!(
3245 actions
3246 .iter()
3247 .all(|a| { !matches!(a, SessionPixAction::Close(SessionPixCloseReason::DepositTimeout)) })
3248 );
3249 }
3250
3251 #[test]
3256 fn useful_progress_extends_idle_only_hard_never_moves() {
3257 let p = pseudonym();
3258 let mut cfg = default_cfg();
3259 cfg.max_recovery_idle = Duration::from_secs(60);
3260 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
3261 let start = Instant::now();
3262 let id = ssa_id(p, 1);
3263
3264 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
3265 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
3266 sup.handle_event(
3267 &SessionPixEvent::DepositConfirmed {
3268 ssa_id: id,
3269 amount: sufficient_balance(),
3270 },
3271 start,
3272 0,
3273 );
3274
3275 let hard_before = sup.ssas.iter().find(|s| s.ssa_id == id).unwrap().recovery_hard_deadline;
3276 let idle_before = sup.ssas.iter().find(|s| s.ssa_id == id).unwrap().recovery_idle_deadline;
3277
3278 sup.handle_event(
3279 &SessionPixEvent::RecoveryProgress(make_progress(id, 10, 50, 1)),
3280 start + Duration::from_secs(55),
3281 5,
3282 );
3283
3284 let ssa = sup.ssas.iter().find(|s| s.ssa_id == id).unwrap();
3285 assert_eq!(ssa.recovery_hard_deadline, hard_before);
3286 assert!(ssa.recovery_idle_deadline > idle_before);
3287 }
3288
3289 #[test]
3290 fn hard_deadline_immutable_under_trickle_progress() {
3291 let p = pseudonym();
3292 let mut cfg = default_cfg();
3293 cfg.max_recovery_idle = Duration::from_secs(10);
3294 cfg.max_recovery_time = Duration::from_secs(30);
3295 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
3296 let start = Instant::now();
3297 let id = ssa_id(p, 1);
3298
3299 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
3300 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
3301 sup.handle_event(
3302 &SessionPixEvent::DepositConfirmed {
3303 ssa_id: id,
3304 amount: sufficient_balance(),
3305 },
3306 start,
3307 0,
3308 );
3309
3310 for secs in [9u64, 19, 29] {
3311 sup.handle_event(
3312 &SessionPixEvent::RecoveryProgress(make_progress(id, secs + 1, 50, 1)),
3313 start + Duration::from_secs(secs),
3314 secs,
3315 );
3316 }
3317
3318 let actions = sup.handle_deadline(start + Duration::from_secs(31), 30);
3319 assert_eq!(actions.len(), 1);
3320 match &actions[0] {
3321 SessionPixAction::Close(SessionPixCloseReason::RecoveryDeadline) => {}
3322 other => panic!("expected RecoveryDeadline, got {other:?}"),
3323 }
3324 }
3325
3326 #[test]
3327 fn idle_expiry_without_service_since_progress_rearms() {
3328 let p = pseudonym();
3329 let mut cfg = default_cfg();
3330 cfg.max_recovery_idle = Duration::from_secs(10);
3331 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
3332 let start = Instant::now();
3333 let id = ssa_id(p, 1);
3334
3335 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
3336 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
3337 sup.handle_event(
3338 &SessionPixEvent::DepositConfirmed {
3339 ssa_id: id,
3340 amount: sufficient_balance(),
3341 },
3342 start,
3343 0,
3344 );
3345
3346 let actions = sup.handle_deadline(start + Duration::from_secs(11), 0);
3347 assert!(
3348 !actions
3349 .iter()
3350 .any(|a| matches!(a, SessionPixAction::Close(SessionPixCloseReason::RecoveryIdle)))
3351 );
3352
3353 assert_eq!(
3355 sup.ssas.iter().find(|s| s.ssa_id == id).unwrap().phase,
3356 SsaPhase::Recovering
3357 );
3358 }
3359
3360 #[test]
3361 fn idle_expiry_with_service_and_no_progress_closes_recovery_idle() {
3362 let p = pseudonym();
3363 let mut cfg = default_cfg();
3364 cfg.max_recovery_idle = Duration::from_secs(10);
3365 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
3366 let start = Instant::now();
3367 let id = ssa_id(p, 1);
3368
3369 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
3370 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
3371 sup.handle_event(
3372 &SessionPixEvent::DepositConfirmed {
3373 ssa_id: id,
3374 amount: sufficient_balance(),
3375 },
3376 start,
3377 5,
3378 );
3379
3380 let actions = sup.handle_deadline(start + Duration::from_secs(11), 5);
3382 assert!(
3383 !actions
3384 .iter()
3385 .any(|a| matches!(a, SessionPixAction::Close(SessionPixCloseReason::RecoveryIdle)))
3386 );
3387
3388 let actions = sup.handle_deadline(start + Duration::from_secs(22), 10);
3390 assert!(
3391 actions
3392 .iter()
3393 .any(|a| matches!(a, SessionPixAction::Close(SessionPixCloseReason::RecoveryIdle)))
3394 );
3395 }
3396
3397 #[test]
3398 fn progress_resamples_served_total_watermark() {
3399 let p = pseudonym();
3400 let mut cfg = default_cfg();
3401 cfg.max_recovery_idle = Duration::from_secs(10);
3402 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
3403 let start = Instant::now();
3404 let id = ssa_id(p, 1);
3405
3406 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
3407 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
3408 sup.handle_event(
3409 &SessionPixEvent::DepositConfirmed {
3410 ssa_id: id,
3411 amount: sufficient_balance(),
3412 },
3413 start,
3414 10,
3415 );
3416
3417 sup.handle_event(
3418 &SessionPixEvent::RecoveryProgress(make_progress(id, 5, 50, 1)),
3419 start + Duration::from_secs(5),
3420 15,
3421 );
3422
3423 let actions = sup.handle_deadline(start + Duration::from_secs(16), 15);
3424 assert!(
3425 !actions
3426 .iter()
3427 .any(|a| matches!(a, SessionPixAction::Close(SessionPixCloseReason::RecoveryIdle)))
3428 );
3429 }
3430
3431 #[test]
3441 fn a_surplus_only_snapshot_is_liveness_without_payment() {
3442 let p = pseudonym();
3443 let mut cfg = default_cfg();
3444 cfg.max_recovery_idle = Duration::from_secs(10);
3445 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
3446 let start = Instant::now();
3447 let id = ssa_id(p, 1);
3448
3449 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
3450 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
3451 sup.handle_event(
3452 &SessionPixEvent::DepositConfirmed {
3453 ssa_id: id,
3454 amount: sufficient_balance(),
3455 },
3456 start,
3457 0,
3458 );
3459
3460 sup.handle_event(
3462 &SessionPixEvent::RecoveryProgress(make_progress(id, 1, 50, 0)),
3463 start + Duration::from_secs(1),
3464 10,
3465 );
3466
3467 for (i, seen) in (2..=6u64).enumerate() {
3470 let actions = sup.handle_event(
3471 &SessionPixEvent::RecoveryProgress(make_progress_seen(id, 1, seen, 50, 0)),
3472 start + Duration::from_secs(2 + i as u64),
3473 20 + 10 * i as u64,
3474 );
3475 assert!(
3476 actions
3477 .iter()
3478 .any(|a| matches!(a, SessionPixAction::ProgressNotification)),
3479 "a surplus share must reset the gate's served-without-progress ceiling"
3480 );
3481 assert!(
3482 !actions.iter().any(|a| matches!(a, SessionPixAction::Close(_))),
3483 "a surplus share must never close the Session"
3484 );
3485 }
3486
3487 let actions = sup.handle_deadline(start + Duration::from_secs(12), 70);
3490 assert!(
3491 !actions
3492 .iter()
3493 .any(|a| matches!(a, SessionPixAction::Close(SessionPixCloseReason::RecoveryIdle))),
3494 "an Entry serving its negotiated surplus is not idle"
3495 );
3496
3497 let ssa = sup.ssas.iter().find(|s| s.ssa_id == id).expect("cycle must be live");
3499 assert_eq!(1, ssa.largest_useful_shares, "surplus must not count as useful shares");
3500 assert_eq!(6, ssa.largest_shares_seen, "every share must count as liveness");
3501 }
3502
3503 #[test]
3509 fn silence_after_a_surplus_run_still_closes_the_session() {
3510 let p = pseudonym();
3511 let mut cfg = default_cfg();
3512 cfg.max_recovery_idle = Duration::from_secs(10);
3513 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
3514 let start = Instant::now();
3515 let id = ssa_id(p, 1);
3516
3517 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
3518 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
3519 sup.handle_event(
3520 &SessionPixEvent::DepositConfirmed {
3521 ssa_id: id,
3522 amount: sufficient_balance(),
3523 },
3524 start,
3525 0,
3526 );
3527 sup.handle_event(
3528 &SessionPixEvent::RecoveryProgress(make_progress_seen(id, 1, 4, 50, 0)),
3529 start + Duration::from_secs(1),
3530 10,
3531 );
3532
3533 let actions = sup.handle_deadline(start + Duration::from_secs(12), 500);
3535 assert!(
3536 actions
3537 .iter()
3538 .any(|a| matches!(a, SessionPixAction::Close(SessionPixCloseReason::RecoveryIdle))),
3539 "a genuinely silent Entry must still be caught"
3540 );
3541 }
3542
3543 #[test]
3544 fn equal_snapshot_is_noop() {
3545 let p = pseudonym();
3546 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
3547 let now = Instant::now();
3548 let id = ssa_id(p, 1);
3549
3550 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
3551 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
3552 sup.handle_event(
3553 &SessionPixEvent::DepositConfirmed {
3554 ssa_id: id,
3555 amount: sufficient_balance(),
3556 },
3557 now,
3558 0,
3559 );
3560
3561 let progress = make_progress(id, 10, 50, 1);
3562 sup.handle_event(&SessionPixEvent::RecoveryProgress(progress), now, 5);
3563
3564 let idle_before = sup.ssas.iter().find(|s| s.ssa_id == id).unwrap().recovery_idle_deadline;
3565
3566 let actions = sup.handle_event(&SessionPixEvent::RecoveryProgress(progress), now, 5);
3567 assert!(actions.is_empty());
3568
3569 assert_eq!(
3570 sup.ssas.iter().find(|s| s.ssa_id == id).unwrap().recovery_idle_deadline,
3571 idle_before
3572 );
3573 }
3574
3575 #[test]
3576 fn lower_snapshot_is_ignored_as_stale() {
3577 let p = pseudonym();
3578 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
3579 let now = Instant::now();
3580 let id = ssa_id(p, 1);
3581
3582 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
3583 sup.handle_event(&SessionPixEvent::RecoveryProgress(make_progress(id, 10, 50, 1)), now, 0);
3584
3585 let actions = sup.handle_event(&SessionPixEvent::RecoveryProgress(make_progress(id, 5, 50, 1)), now, 0);
3589 assert!(actions.is_empty(), "stale snapshot should be ignored, got: {actions:?}");
3590 }
3591
3592 #[test]
3593 fn inconsistent_target_closes_as_counter_regression() {
3594 let p = pseudonym();
3595 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
3596 let now = Instant::now();
3597 let id = ssa_id(p, 1);
3598
3599 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
3601 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
3602 sup.handle_event(
3603 &SessionPixEvent::DepositConfirmed {
3604 ssa_id: id,
3605 amount: sufficient_balance(),
3606 },
3607 now,
3608 0,
3609 );
3610
3611 let actions = sup.handle_event(&SessionPixEvent::RecoveryProgress(make_progress(id, 1, 99, 0)), now, 0);
3613 assert_eq!(actions.len(), 1);
3614 assert!(matches!(
3615 actions[0],
3616 SessionPixAction::Close(SessionPixCloseReason::CounterRegression)
3617 ));
3618 }
3619
3620 #[test]
3631 fn an_unverifiable_share_counts_even_before_the_request_is_confirmed() {
3632 let p = pseudonym();
3633 let (mut sup, actions) = SessionPixSupervisor::new(SupervisorConfig::default(), dims(10, 5), p, Instant::now());
3634 let now = Instant::now();
3635
3636 let id = match actions.as_slice() {
3637 [SessionPixAction::RequestSsa { ssa_ids, .. }] => ssa_ids[0],
3638 other => panic!("expected one RequestSsa, got {other:?}"),
3639 };
3640
3641 let actions = sup.handle_event(
3643 &SessionPixEvent::UnverifiableShares {
3644 ssa_id: id,
3645 observed_total: 1,
3646 peer: crate::supervision::test_peer(),
3647 },
3648 now,
3649 0,
3650 );
3651
3652 assert!(
3653 matches!(
3654 actions.as_slice(),
3655 [SessionPixAction::Close(SessionPixCloseReason::UnverifiableShares)]
3656 ),
3657 "expected a close, got {actions:?}"
3658 );
3659 }
3660
3661 #[test]
3669 fn any_observed_total_closes_on_the_first_report() {
3670 for observed_total in [1, 2, u64::MAX] {
3671 let p = pseudonym();
3672 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
3673 let now = Instant::now();
3674 let id = ssa_id(p, 1);
3675
3676 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
3677
3678 let actions = sup.handle_event(
3679 &SessionPixEvent::UnverifiableShares {
3680 ssa_id: id,
3681 observed_total,
3682 peer: crate::supervision::test_peer(),
3683 },
3684 now,
3685 0,
3686 );
3687
3688 assert!(
3689 matches!(
3690 actions.as_slice(),
3691 [SessionPixAction::Close(SessionPixCloseReason::UnverifiableShares)]
3692 ),
3693 "observed_total {observed_total} must close on the first report, got {actions:?}"
3694 );
3695 }
3696 }
3697
3698 #[test]
3705 fn an_unverifiable_share_closes_the_session_even_with_live_siblings() {
3706 let p = pseudonym();
3707 let mut cfg = default_cfg();
3708 cfg.ssas_per_request = 3;
3709 cfg.max_failed_cycles = 10;
3710 let (mut sup, actions) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
3711 let now = Instant::now();
3712
3713 let ids = match actions.as_slice() {
3714 [SessionPixAction::RequestSsa { ssa_ids, .. }] => ssa_ids.clone(),
3715 other => panic!("expected one RequestSsa, got {other:?}"),
3716 };
3717 assert_eq!(3, ids.len());
3718 for id in &ids {
3719 sup.handle_event(&SessionPixEvent::SsaRequestSent(*id), now, 0);
3720 }
3721
3722 let actions = sup.handle_event(
3724 &SessionPixEvent::UnverifiableShares {
3725 ssa_id: ids[2],
3726 observed_total: 1,
3727 peer: crate::supervision::test_peer(),
3728 },
3729 now,
3730 0,
3731 );
3732
3733 assert!(
3734 matches!(
3735 actions.as_slice(),
3736 [SessionPixAction::Close(SessionPixCloseReason::UnverifiableShares)]
3737 ),
3738 "a live batch must not absorb an unverifiable-share report, got {actions:?}"
3739 );
3740 }
3741
3742 #[test]
3747 fn almost_recovered_while_recovering_requests_next_once() {
3748 let p = pseudonym();
3749 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
3750 let now = Instant::now();
3751 let id1 = ssa_id(p, 1);
3752
3753 sup.handle_event(&SessionPixEvent::SsaRequestSent(id1), now, 0);
3754 sup.handle_event(&SessionPixEvent::CommitmentVerified(id1), now, 0);
3755 sup.handle_event(
3756 &SessionPixEvent::DepositConfirmed {
3757 ssa_id: id1,
3758 amount: sufficient_balance(),
3759 },
3760 now,
3761 0,
3762 );
3763
3764 let actions = sup.handle_event(&SessionPixEvent::AlmostRecovered(id1), now, 0);
3765 assert_eq!(actions.len(), 1);
3766 match &actions[0] {
3767 SessionPixAction::RequestSsa { ssa_ids, .. } => {
3768 assert_eq!(ssa_ids[0].ssa_index(), SsaIndex::new(2).unwrap());
3769 }
3770 other => panic!("expected RequestSsa, got {other:?}"),
3771 }
3772
3773 let actions = sup.handle_event(&SessionPixEvent::AlmostRecovered(id1), now, 0);
3774 assert!(actions.is_empty());
3775 }
3776
3777 fn share_order_cfg(sample: u64, fraction: f64) -> SupervisorConfig {
3780 SupervisorConfig {
3781 ssas_per_request: 3,
3782 min_share_order_sample: sample,
3783 max_off_front_share_fraction: fraction,
3784 ..default_cfg()
3785 }
3786 }
3787
3788 #[test]
3795 fn conforming_service_never_trips_the_share_order_ratio() {
3796 let p = pseudonym();
3797 let (mut sup, _) = SessionPixSupervisor::new(share_order_cfg(1000, 0.25), dims(100, 10), p, Instant::now());
3798 let now = Instant::now();
3799 fund_batch(&mut sup, p, 3, now);
3800 let target = sup.dims.target_useful_shares();
3801
3802 for useful in [200, 400, 600, 800] {
3803 let actions = sup.handle_event(
3804 &SessionPixEvent::RecoveryProgress(make_progress(ssa_id(p, 1), useful, target, 10)),
3805 now,
3806 useful,
3807 );
3808 assert!(
3809 matches!(actions.as_slice(), [SessionPixAction::ProgressNotification]),
3810 "front-cycle service is what is supposed to happen, got {actions:?}"
3811 );
3812 }
3813
3814 let actions = sup.handle_event(
3816 &SessionPixEvent::RecoveryProgress(make_progress(ssa_id(p, 2), 50, target, 1)),
3817 now,
3818 850,
3819 );
3820 assert!(
3821 actions.is_empty(),
3822 "off-front progress must neither convict nor reopen the gate"
3823 );
3824 assert!(!sup.closed);
3825 }
3826
3827 #[test]
3833 fn spreading_a_batch_across_its_cycles_trips_the_share_order_ratio() {
3834 const SAMPLE: u64 = 1000;
3835
3836 let p = pseudonym();
3837 let (mut sup, _) = SessionPixSupervisor::new(share_order_cfg(SAMPLE, 0.25), dims(100, 10), p, Instant::now());
3838 let now = Instant::now();
3839 fund_batch(&mut sup, p, 3, now);
3840 let target = sup.dims.target_useful_shares();
3841
3842 let mut closed_at = None;
3843 'outer: for round in 1..=5u64 {
3844 for cycle in 1..=3u32 {
3845 let actions = sup.handle_event(
3846 &SessionPixEvent::RecoveryProgress(make_progress(ssa_id(p, cycle), round * 100, target, 10)),
3847 now,
3848 round * 300,
3849 );
3850 let sample = sup.front_useful + sup.off_front_useful;
3851 if let [SessionPixAction::Close(reason)] = actions.as_slice() {
3852 assert_eq!(*reason, SessionPixCloseReason::BatchServedOutOfOrder);
3853 closed_at = Some(sample);
3854 break 'outer;
3855 }
3856 assert!(
3857 sample < SAMPLE,
3858 "at {sample} shares the evidence was in and the 2:1 split should have closed the Session"
3859 );
3860 }
3861 }
3862
3863 let closed_at = closed_at.expect("spreading across three cycles must close the Session");
3864 assert!(
3865 closed_at >= SAMPLE,
3866 "conviction at {closed_at} shares is below the evidence floor of {SAMPLE}"
3867 );
3868 }
3869
3870 #[test]
3875 fn share_order_accounting_resets_when_the_front_cycle_completes() {
3876 let p = pseudonym();
3877 let (mut sup, _) = SessionPixSupervisor::new(share_order_cfg(1000, 0.25), dims(100, 10), p, Instant::now());
3878 let now = Instant::now();
3879 fund_batch(&mut sup, p, 3, now);
3880 let target = sup.dims.target_useful_shares();
3881
3882 sup.handle_event(
3883 &SessionPixEvent::RecoveryProgress(make_progress(ssa_id(p, 1), 500, target, 50)),
3884 now,
3885 500,
3886 );
3887 sup.handle_event(
3888 &SessionPixEvent::RecoveryProgress(make_progress(ssa_id(p, 2), 100, target, 10)),
3889 now,
3890 600,
3891 );
3892 assert_eq!(sup.share_order_front, Some(SsaIndex::new(1).unwrap()));
3893 assert_eq!((sup.front_useful, sup.off_front_useful), (500, 100));
3894
3895 sup.handle_event(&SessionPixEvent::Recovered(ssa_id(p, 1)), now, 600);
3896 assert_eq!(
3897 sup.share_order_front,
3898 Some(SsaIndex::new(2).unwrap()),
3899 "cycle 2 is the front now"
3900 );
3901 assert_eq!(
3902 (sup.front_useful, sup.off_front_useful),
3903 (0, 0),
3904 "the window is one cycle's service, so it starts empty on the new front"
3905 );
3906 }
3907
3908 #[test]
3916 fn no_verdict_is_reached_below_the_evidence_floor() {
3917 const SAMPLE: u64 = 1000;
3918
3919 let p = pseudonym();
3920 let (mut sup, _) = SessionPixSupervisor::new(share_order_cfg(SAMPLE, 0.0), dims(100, 10), p, Instant::now());
3921 let now = Instant::now();
3922 fund_batch(&mut sup, p, 3, now);
3923 let target = sup.dims.target_useful_shares();
3924
3925 for useful in [300, 600, 900] {
3927 let actions = sup.handle_event(
3928 &SessionPixEvent::RecoveryProgress(make_progress(ssa_id(p, 3), useful, target, 10)),
3929 now,
3930 useful,
3931 );
3932 assert!(actions.is_empty(), "{useful} shares is below the floor and off-front");
3933 }
3934
3935 let actions = sup.handle_event(
3936 &SessionPixEvent::RecoveryProgress(make_progress(ssa_id(p, 3), SAMPLE, target, 10)),
3937 now,
3938 SAMPLE,
3939 );
3940 assert!(
3941 matches!(
3942 actions.as_slice(),
3943 [SessionPixAction::Close(SessionPixCloseReason::BatchServedOutOfOrder)]
3944 ),
3945 "at the floor the verdict must be reached, got {actions:?}"
3946 );
3947 }
3948
3949 #[test]
3962 fn a_stray_share_on_a_queued_cycle_must_not_start_its_idle_clock() {
3963 const BATCH: u32 = 3;
3964
3965 let p = pseudonym();
3966 let cfg = SupervisorConfig {
3967 ssas_per_request: BATCH as usize,
3968 max_recovery_idle: Duration::from_secs(10),
3969 max_recovery_time: Duration::from_secs(3600),
3970 ..default_cfg()
3971 };
3972 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
3973 let start = Instant::now();
3974 fund_batch(&mut sup, p, BATCH, start);
3975 let target = sup.dims.target_useful_shares();
3976
3977 sup.handle_event(
3979 &SessionPixEvent::RecoveryProgress(make_progress(ssa_id(p, 2), 1, target, 0)),
3980 start + Duration::from_secs(1),
3981 1_000,
3982 );
3983
3984 let queued = sup.ssas.iter().find(|s| s.ssa_id == ssa_id(p, 2)).expect("cycle 2");
3985 assert_eq!(
3986 (queued.recovery_hard_deadline, queued.recovery_idle_deadline),
3987 (None, None),
3988 "an off-front share must leave a queued cycle off both clocks, not just the hard one"
3989 );
3990
3991 for step in 1..=3u64 {
3994 sup.handle_event(
3995 &SessionPixEvent::RecoveryProgress(make_progress(ssa_id(p, 1), step, target, 0)),
3996 start + Duration::from_secs(1 + step * 3),
3997 1_000 + step * 1_000,
3998 );
3999 }
4000
4001 let actions = sup.handle_deadline(start + Duration::from_secs(12), 5_000);
4002 assert!(
4003 actions.is_empty(),
4004 "no cycle may be retired or closed while the front is being served, got {actions:?}"
4005 );
4006 assert_eq!(sup.ssas.len(), BATCH as usize, "the whole batch must still be live");
4007 assert_eq!(sup.failed_cycles, 0, "nothing may be charged as a failed cycle");
4008 }
4009
4010 #[test]
4022 fn a_queued_cycle_starts_its_recovery_clocks_only_at_the_front_of_the_batch() {
4023 const BATCH: u32 = 3;
4024
4025 let p = pseudonym();
4026 let cfg = SupervisorConfig {
4027 ssas_per_request: BATCH as usize,
4028 max_recovery_idle: Duration::from_secs(10),
4029 max_recovery_time: Duration::from_secs(3600),
4030 ..default_cfg()
4031 };
4032 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
4033 let start = Instant::now();
4034 fund_batch(&mut sup, p, BATCH, start);
4035 let target = sup.dims.target_useful_shares();
4036
4037 let armed = |sup: &SessionPixSupervisor, idx: u32| {
4038 let ssa = sup
4039 .ssas
4040 .iter()
4041 .find(|s| s.ssa_id == ssa_id(p, idx))
4042 .expect("cycle must exist");
4043 (
4044 ssa.recovery_hard_deadline.is_some(),
4045 ssa.recovery_idle_deadline.is_some(),
4046 )
4047 };
4048
4049 assert_eq!(
4050 armed(&sup, 1),
4051 (true, true),
4052 "the front cycle's clocks run from funding"
4053 );
4054 for queued in 2..=BATCH {
4055 assert_eq!(
4056 armed(&sup, queued),
4057 (false, false),
4058 "cycle {queued} is queued behind the front and must not be on the clock yet"
4059 );
4060 }
4061
4062 for step in 1..=10u64 {
4065 let at = start + Duration::from_secs(step * 5);
4066 let served = step * 1000;
4067 sup.handle_event(
4068 &SessionPixEvent::RecoveryProgress(make_progress(ssa_id(p, 1), step, target, 1)),
4069 at,
4070 served,
4071 );
4072 let actions = sup.handle_deadline(at, served);
4073 assert!(
4074 !actions
4075 .iter()
4076 .any(|a| matches!(a, SessionPixAction::Close(_) | SessionPixAction::RetireSsa(_))),
4077 "no cycle may be closed or retired at +{}s, got {actions:?}",
4078 step * 5
4079 );
4080 }
4081 assert_eq!(sup.ssas.len(), BATCH as usize, "the whole batch must still be live");
4082
4083 let handover = start + Duration::from_secs(60);
4086 sup.handle_event(&SessionPixEvent::Recovered(ssa_id(p, 1)), handover, 10_000);
4087 assert_eq!(
4088 armed(&sup, 2),
4089 (false, false),
4090 "cycle 2 is still behind the paid FIFO tail"
4091 );
4092 assert_eq!(
4093 armed(&sup, 3),
4094 (false, false),
4095 "cycle 3 is still queued and must stay off the clock"
4096 );
4097
4098 let fifo_boundary = handover + Duration::from_secs(5);
4099 let max_seen = max_cycle_shares(&sup);
4100 sup.handle_event(
4101 &SessionPixEvent::RecoveryProgress(make_recovered_tail_progress(&sup, ssa_id(p, 1), max_seen)),
4102 fifo_boundary,
4103 10_100,
4104 );
4105 assert_eq!(armed(&sup, 2), (true, true), "cycle 2 starts only at the FIFO boundary");
4106
4107 let ssa2 = sup.ssas.iter().find(|s| s.ssa_id == ssa_id(p, 2)).expect("cycle 2");
4108 assert_eq!(
4109 ssa2.recovery_hard_deadline,
4110 fifo_boundary.checked_add(Duration::from_secs(3600)),
4111 "cycle 2's ceiling must be measured from its own turn, not from its deposit"
4112 );
4113 assert_eq!(
4114 ssa2.served_total_at_last_progress, 10_100,
4115 "the idle gate's baseline must be re-based at the handover, or the queue wait is charged to it"
4116 );
4117 }
4118
4119 #[test]
4131 fn only_the_last_ssa_of_a_batch_asks_for_the_next_one() {
4132 const BATCH: usize = 3;
4133
4134 let p = pseudonym();
4135 let cfg = SupervisorConfig {
4136 ssas_per_request: BATCH,
4137 ..default_cfg()
4138 };
4139 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
4140 let now = Instant::now();
4141
4142 for idx in 1..=BATCH as u32 {
4144 let id = ssa_id(p, idx);
4145 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
4146 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
4147 sup.handle_event(
4148 &SessionPixEvent::DepositConfirmed {
4149 ssa_id: id,
4150 amount: sufficient_balance(),
4151 },
4152 now,
4153 0,
4154 );
4155 }
4156 assert_eq!(sup.next_ssa_index, BATCH as u32 + 1, "one batch allocated so far");
4157
4158 for idx in 1..BATCH as u32 {
4160 let actions = sup.handle_event(&SessionPixEvent::AlmostRecovered(ssa_id(p, idx)), now, 0);
4161 assert!(
4162 actions.is_empty(),
4163 "SSA {idx} is not the last of the batch and must not ask for a successor, got {actions:?}"
4164 );
4165 assert_eq!(
4166 sup.next_ssa_index,
4167 BATCH as u32 + 1,
4168 "SSA {idx} must not allocate any index"
4169 );
4170 }
4171
4172 let last = ssa_id(p, BATCH as u32);
4174 let actions = sup.handle_event(&SessionPixEvent::AlmostRecovered(last), now, 0);
4175 assert_eq!(actions.len(), 1, "the last SSA of the batch must ask exactly once");
4176 match &actions[0] {
4177 SessionPixAction::RequestSsa { ssa_ids, .. } => assert_eq!(
4178 ssa_ids.iter().map(|i| i.ssa_index().get()).collect::<Vec<_>>(),
4179 (BATCH as u32 + 1..=2 * BATCH as u32).collect::<Vec<_>>(),
4180 "the successor batch must be the next contiguous run"
4181 ),
4182 other => panic!("expected RequestSsa, got {other:?}"),
4183 }
4184
4185 assert!(
4187 sup.handle_event(&SessionPixEvent::AlmostRecovered(last), now, 0)
4188 .is_empty(),
4189 "a repeated AlmostRecovered on the last member must not ask again"
4190 );
4191 assert_eq!(sup.next_ssa_index, 2 * BATCH as u32 + 1);
4192 assert_eq!(
4193 sup.live_cycle_count(),
4194 sup.reserved_cycle_slots(),
4195 "two full batches sit exactly on the admission reservation"
4196 );
4197 }
4198
4199 #[test]
4200 fn a_third_live_batch_is_deferred_even_when_share_order_policy_allows_it() {
4201 const BATCH: usize = 2;
4202
4203 let p = pseudonym();
4204 let cfg = SupervisorConfig {
4205 ssas_per_request: BATCH,
4206 max_off_front_share_fraction: 1.0,
4207 ..default_cfg()
4208 };
4209 let now = Instant::now();
4210 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, now);
4211
4212 fund_batch(&mut sup, p, BATCH as u32, now);
4213 let actions = sup.handle_event(&SessionPixEvent::AlmostRecovered(ssa_id(p, 2)), now, 0);
4214 let second_batch = match actions.as_slice() {
4215 [SessionPixAction::RequestSsa { ssa_ids, .. }] => ssa_ids.clone(),
4216 other => panic!("expected the second batch, got {other:?}"),
4217 };
4218 for id in second_batch {
4219 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
4220 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
4221 sup.handle_event(
4222 &SessionPixEvent::DepositConfirmed {
4223 ssa_id: id,
4224 amount: sufficient_balance(),
4225 },
4226 now,
4227 0,
4228 );
4229 }
4230
4231 let actions = sup.handle_event(&SessionPixEvent::AlmostRecovered(ssa_id(p, 4)), now, 0);
4232 assert!(
4233 actions
4234 .iter()
4235 .all(|action| !matches!(action, SessionPixAction::RequestSsa { .. })),
4236 "two live batches already consume the reservation, got {actions:?}"
4237 );
4238 assert_eq!(sup.ssas.len(), 2 * BATCH, "a third batch must not be allocated");
4239 }
4240
4241 #[test]
4242 fn a_deferred_batch_retries_only_after_the_older_generation_is_released() {
4243 let p = pseudonym();
4244 let cfg = SupervisorConfig {
4245 ssas_per_request: 2,
4246 max_failed_cycles: 2,
4247 max_off_front_share_fraction: 1.0,
4248 ..default_cfg()
4249 };
4250 let now = Instant::now();
4251 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, now);
4252
4253 commit_unfunded(&mut sup, p, 1..=1, now);
4255 let second = ssa_id(p, 2);
4256 sup.handle_event(&SessionPixEvent::SsaRequestSent(second), now, 0);
4257 sup.handle_event(&SessionPixEvent::CommitmentVerified(second), now, 0);
4258 sup.handle_event(
4259 &SessionPixEvent::DepositConfirmed {
4260 ssa_id: second,
4261 amount: sufficient_balance(),
4262 },
4263 now,
4264 0,
4265 );
4266 assert!(
4267 sup.handle_event(&SessionPixEvent::AlmostRecovered(second), now, 0)
4268 .iter()
4269 .any(|action| matches!(action, SessionPixAction::RequestSsa { .. }))
4270 );
4271
4272 for idx in 3..=4 {
4273 let id = ssa_id(p, idx);
4274 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
4275 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
4276 sup.handle_event(
4277 &SessionPixEvent::DepositConfirmed {
4278 ssa_id: id,
4279 amount: sufficient_balance(),
4280 },
4281 now,
4282 0,
4283 );
4284 }
4285 assert!(
4286 sup.handle_event(&SessionPixEvent::AlmostRecovered(ssa_id(p, 4)), now, 0)
4287 .iter()
4288 .all(|action| !matches!(action, SessionPixAction::RequestSsa { .. }))
4289 );
4290
4291 let actions = sup.handle_event(&SessionPixEvent::DepositObserverClosed(ssa_id(p, 1)), now, 0);
4293 assert!(
4294 actions
4295 .iter()
4296 .all(|action| !matches!(action, SessionPixAction::RequestSsa { .. }))
4297 );
4298
4299 let actions = sup.handle_deadline(now + default_cfg().max_recovery_idle, 1);
4302 let retire_position = actions
4303 .iter()
4304 .position(|action| matches!(action, SessionPixAction::RetireSsa(id) if *id == second));
4305 let request_position = actions.iter().position(|action| {
4306 matches!(action, SessionPixAction::RequestSsa { ssa_ids, .. }
4307 if ssa_ids.iter().map(|id| id.ssa_index().get()).eq(5..=6))
4308 });
4309 assert!(
4310 matches!((retire_position, request_position), (Some(retire), Some(request)) if retire < request),
4311 "the old generation must retire before batch 3 is requested, got {actions:?}"
4312 );
4313 assert_eq!(sup.live_batch_count(), 2);
4314 assert_eq!(sup.live_cycle_count(), 4);
4315 }
4316
4317 #[test]
4324 fn retiring_the_last_ssa_of_a_batch_hands_the_successor_gate_on() {
4325 const BATCH: usize = 3;
4326
4327 let p = pseudonym();
4328 let cfg = SupervisorConfig {
4329 ssas_per_request: BATCH,
4330 ..default_cfg()
4331 };
4332 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
4333 let now = Instant::now();
4334
4335 for idx in 1..BATCH as u32 {
4337 let id = ssa_id(p, idx);
4338 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
4339 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
4340 sup.handle_event(
4341 &SessionPixEvent::DepositConfirmed {
4342 ssa_id: id,
4343 amount: sufficient_balance(),
4344 },
4345 now,
4346 0,
4347 );
4348 }
4349 let last = ssa_id(p, BATCH as u32);
4350 sup.handle_event(&SessionPixEvent::SsaRequestSent(last), now, 0);
4351 sup.handle_event(&SessionPixEvent::CommitmentVerified(last), now, 0);
4352
4353 let actions = sup.handle_event(&SessionPixEvent::DepositObserverClosed(last), now, 0);
4355 assert!(
4356 actions
4357 .iter()
4358 .any(|a| matches!(a, SessionPixAction::RetireSsa(id) if *id == last)),
4359 "the unfunded last cycle must be retired, not close the Session, got {actions:?}"
4360 );
4361 assert!(!sup.closed, "surviving siblings must keep the Session alive");
4362
4363 assert!(
4365 sup.handle_event(&SessionPixEvent::AlmostRecovered(ssa_id(p, 1)), now, 0)
4366 .is_empty(),
4367 "cycle 1 is still not the last survivor and must stay silent"
4368 );
4369 let actions = sup.handle_event(&SessionPixEvent::AlmostRecovered(ssa_id(p, 2)), now, 0);
4370 assert_eq!(
4371 actions.len(),
4372 1,
4373 "the newest survivor must have inherited the successor gate, got {actions:?}"
4374 );
4375 match &actions[0] {
4376 SessionPixAction::RequestSsa { ssa_ids, .. } => assert_eq!(
4377 ssa_ids.iter().map(|i| i.ssa_index().get()).collect::<Vec<_>>(),
4378 (BATCH as u32 + 1..=2 * BATCH as u32).collect::<Vec<_>>(),
4379 "the successor batch still starts past the whole retired batch"
4380 ),
4381 other => panic!("expected RequestSsa, got {other:?}"),
4382 }
4383 }
4384
4385 #[test]
4396 fn the_second_lost_cycle_closes_a_batched_session() {
4397 const BATCH: usize = 4;
4398
4399 let p = pseudonym();
4400 let cfg = SupervisorConfig {
4401 ssas_per_request: BATCH,
4402 max_failed_cycles: 1,
4403 ..default_cfg()
4404 };
4405 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
4406 let now = Instant::now();
4407
4408 fund_batch(&mut sup, p, 2, now);
4411 commit_unfunded(&mut sup, p, 3..=BATCH as u32, now);
4412
4413 let actions = sup.handle_event(&SessionPixEvent::DepositObserverClosed(ssa_id(p, 3)), now, 0);
4415 assert!(
4416 actions
4417 .iter()
4418 .any(|a| matches!(a, SessionPixAction::RetireSsa(id) if *id == ssa_id(p, 3))),
4419 "the first lost cycle must be retired rather than close the Session, got {actions:?}"
4420 );
4421 assert!(!sup.closed, "one loss is inside the limit");
4422
4423 let actions = sup.handle_event(&SessionPixEvent::DepositObserverClosed(ssa_id(p, 4)), now, 0);
4427 assert!(
4428 actions
4429 .iter()
4430 .any(|a| matches!(a, SessionPixAction::Close(SessionPixCloseReason::DepositObserverClosed))),
4431 "the second lost cycle must close the Session, got {actions:?}"
4432 );
4433 assert!(sup.closed);
4434 assert_eq!(2, sup.failed_cycles);
4435 }
4436
4437 #[test]
4443 fn a_raised_failure_limit_tolerates_exactly_that_many_losses() {
4444 const BATCH: usize = 6;
4445
4446 let p = pseudonym();
4447 let cfg = SupervisorConfig {
4448 ssas_per_request: BATCH,
4449 max_failed_cycles: 3,
4450 ..default_cfg()
4451 };
4452 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
4453 let now = Instant::now();
4454
4455 fund_batch(&mut sup, p, 1, now);
4457 commit_unfunded(&mut sup, p, 2..=BATCH as u32, now);
4458
4459 for idx in 2..=4 {
4460 sup.handle_event(&SessionPixEvent::DepositObserverClosed(ssa_id(p, idx)), now, 0);
4461 assert!(!sup.closed, "loss {} of 3 is inside the limit", idx - 1);
4462 }
4463
4464 sup.handle_event(&SessionPixEvent::DepositObserverClosed(ssa_id(p, 5)), now, 0);
4465 assert!(sup.closed, "the fourth loss is over a limit of three");
4466 }
4467
4468 #[test]
4469 fn almost_recovered_while_awaiting_deposit_defers_request() {
4470 let p = pseudonym();
4471 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
4472 let now = Instant::now();
4473 let id1 = ssa_id(p, 1);
4474
4475 sup.handle_event(&SessionPixEvent::SsaRequestSent(id1), now, 0);
4476 sup.handle_event(&SessionPixEvent::CommitmentVerified(id1), now, 0);
4477
4478 let actions = sup.handle_event(&SessionPixEvent::AlmostRecovered(id1), now, 0);
4479 assert!(actions.is_empty());
4480
4481 let actions = sup.handle_event(
4482 &SessionPixEvent::DepositConfirmed {
4483 ssa_id: id1,
4484 amount: sufficient_balance(),
4485 },
4486 now,
4487 0,
4488 );
4489 assert_eq!(actions.len(), 2);
4490 assert!(matches!(actions[0], SessionPixAction::ReleaseService));
4491 match &actions[1] {
4492 SessionPixAction::RequestSsa { ssa_ids, .. } => {
4493 assert_eq!(ssa_ids[0].ssa_index(), SsaIndex::new(2).unwrap());
4494 }
4495 other => panic!("expected RequestSsa, got {other:?}"),
4496 }
4497 }
4498
4499 #[test]
4500 fn recovered_without_prior_early_event_falls_back_to_request() {
4501 let p = pseudonym();
4502 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
4503 let now = Instant::now();
4504 let id1 = ssa_id(p, 1);
4505
4506 sup.handle_event(&SessionPixEvent::SsaRequestSent(id1), now, 0);
4507 sup.handle_event(&SessionPixEvent::CommitmentVerified(id1), now, 0);
4508 sup.handle_event(
4509 &SessionPixEvent::DepositConfirmed {
4510 ssa_id: id1,
4511 amount: sufficient_balance(),
4512 },
4513 now,
4514 0,
4515 );
4516
4517 let actions = sup.handle_event(&SessionPixEvent::Recovered(id1), now, 0);
4518 assert_eq!(actions.len(), 2, "expected gate reset followed by the fallback request");
4519 assert!(matches!(actions[0], SessionPixAction::ProgressNotification));
4520 match &actions[1] {
4521 SessionPixAction::RequestSsa { ssa_ids, .. } => {
4522 assert_eq!(ssa_ids[0].ssa_index(), SsaIndex::new(2).unwrap());
4523 }
4524 other => panic!("expected RequestSsa, got {other:?}"),
4525 }
4526 }
4527
4528 #[test]
4529 fn recovered_with_prior_early_event_does_not_fallback() {
4530 let p = pseudonym();
4531 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
4532 let now = Instant::now();
4533 let id1 = ssa_id(p, 1);
4534
4535 sup.handle_event(&SessionPixEvent::SsaRequestSent(id1), now, 0);
4536 sup.handle_event(&SessionPixEvent::CommitmentVerified(id1), now, 0);
4537 sup.handle_event(
4538 &SessionPixEvent::DepositConfirmed {
4539 ssa_id: id1,
4540 amount: sufficient_balance(),
4541 },
4542 now,
4543 0,
4544 );
4545
4546 sup.handle_event(&SessionPixEvent::AlmostRecovered(id1), now, 0);
4547 let actions = sup.handle_event(&SessionPixEvent::Recovered(id1), now, 0);
4548 assert!(
4549 matches!(actions.as_slice(), [SessionPixAction::ProgressNotification]),
4550 "the existing gate is reset and now belongs to the predecessor's bounded tail: {actions:?}"
4551 );
4552 }
4553
4554 #[test]
4559 fn close_action_emitted_at_most_once() {
4560 let p = pseudonym();
4561 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
4562 let now = Instant::now();
4563 let id = ssa_id(p, 1);
4564
4565 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
4566 sup.handle_deadline(now + Duration::from_secs(100), 0);
4567 assert!(sup.closed);
4568
4569 let empty = sup.handle_event(&SessionPixEvent::SsaRequestSent(ssa_id(p, 999)), now, 0);
4570 assert!(empty.is_empty());
4571 let empty = sup.handle_deadline(now + Duration::from_secs(200), 0);
4572 assert!(empty.is_empty());
4573 }
4574
4575 #[test]
4576 fn all_events_after_close_are_ignored() {
4577 let p = pseudonym();
4578 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
4579 let now = Instant::now();
4580 let id = ssa_id(p, 1);
4581
4582 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
4583 sup.handle_deadline(now + Duration::from_secs(100), 0);
4584 assert!(sup.closed);
4585
4586 for ev in &[
4587 SessionPixEvent::SsaRequestSent(ssa_id(p, 2)),
4588 SessionPixEvent::CommitmentVerified(ssa_id(p, 2)),
4589 SessionPixEvent::DepositConfirmed {
4590 ssa_id: ssa_id(p, 2),
4591 amount: sufficient_balance(),
4592 },
4593 SessionPixEvent::DepositObserverClosed(ssa_id(p, 2)),
4594 SessionPixEvent::RecoveryProgress(make_progress(ssa_id(p, 2), 1, 50, 0)),
4595 SessionPixEvent::AlmostRecovered(ssa_id(p, 2)),
4596 SessionPixEvent::Recovered(ssa_id(p, 2)),
4597 SessionPixEvent::UnverifiableShares {
4598 ssa_id: ssa_id(p, 2),
4599 observed_total: 1,
4600 peer: crate::supervision::test_peer(),
4601 },
4602 ] {
4603 assert!(
4604 sup.handle_event(ev, now, 0).is_empty(),
4605 "event should be ignored after close"
4606 );
4607 }
4608 }
4609
4610 #[test]
4615 fn next_deadline_none_when_no_ssas() {
4616 let (sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), pseudonym(), Instant::now());
4617 assert!(sup.next_deadline().is_none());
4618 }
4619
4620 #[test]
4621 fn next_deadline_returns_earliest() {
4622 let p = pseudonym();
4623 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
4624 let now = Instant::now();
4625
4626 let id1 = ssa_id(p, 1);
4629 sup.handle_event(&SessionPixEvent::SsaRequestSent(id1), now, 0);
4630 let id2 = ssa_id(p, 2);
4631 sup.handle_event(&SessionPixEvent::SsaRequestSent(id2), now + Duration::from_secs(40), 0);
4632
4633 let dl = sup.next_deadline().unwrap();
4634
4635 let expected = now + Duration::from_secs(20);
4636 assert!((dl - expected).as_millis() < 10, "expected {expected:?}, got {dl:?}");
4637 }
4638
4639 #[test]
4646 fn pipelining_a_second_ssa_leaves_the_first_ones_deadlines_alone() {
4647 let p = pseudonym();
4648 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
4649 let now = Instant::now();
4650
4651 let id1 = ssa_id(p, 1);
4652 sup.handle_event(&SessionPixEvent::SsaRequestSent(id1), now, 0);
4653 sup.handle_event(&SessionPixEvent::CommitmentVerified(id1), now, 0);
4654 sup.handle_event(
4655 &SessionPixEvent::DepositConfirmed {
4656 ssa_id: id1,
4657 amount: sufficient_balance(),
4658 },
4659 now,
4660 0,
4661 );
4662
4663 let before = sup.ssas.iter().find(|s| s.ssa_id == id1).unwrap();
4664 assert_eq!(before.phase, SsaPhase::Recovering);
4665 let (hard_before, idle_before) = (before.recovery_hard_deadline, before.recovery_idle_deadline);
4666
4667 let later = now + Duration::from_secs(5);
4669 let actions = sup.handle_event(&SessionPixEvent::AlmostRecovered(id1), later, 0);
4670 let id2 = match actions.as_slice() {
4671 [SessionPixAction::RequestSsa { ssa_ids, .. }] => ssa_ids[0],
4672 other => panic!("expected exactly one RequestSsa, got {other:?}"),
4673 };
4674 assert_eq!(id2.ssa_index().get(), 2, "the pipelined SSA must take the next index");
4675
4676 sup.handle_event(&SessionPixEvent::SsaRequestSent(id2), later, 0);
4677 sup.handle_event(&SessionPixEvent::CommitmentVerified(id2), later, 0);
4678
4679 let after = sup.ssas.iter().find(|s| s.ssa_id == id1).unwrap();
4680 assert_eq!(
4681 after.phase,
4682 SsaPhase::Recovering,
4683 "pipelining moved the first SSA's phase"
4684 );
4685 assert_eq!(
4686 after.recovery_hard_deadline, hard_before,
4687 "pipelining moved the first SSA's hard recovery deadline"
4688 );
4689 assert_eq!(
4690 after.recovery_idle_deadline, idle_before,
4691 "pipelining moved the first SSA's idle recovery deadline"
4692 );
4693
4694 let second = sup.ssas.iter().find(|s| s.ssa_id == id2).unwrap();
4696 assert_eq!(second.phase, SsaPhase::AwaitingDeposit);
4697 assert!(second.deposit_deadline.is_some());
4698 }
4699
4700 #[test]
4705 fn ssa_index_overflow_fails_closed() {
4706 let p = pseudonym();
4707 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
4708 let now = Instant::now();
4709
4710 let id1 = ssa_id(p, 1);
4711 sup.handle_event(&SessionPixEvent::SsaRequestSent(id1), now, 0);
4712 sup.handle_event(&SessionPixEvent::CommitmentVerified(id1), now, 0);
4713 sup.handle_event(
4714 &SessionPixEvent::DepositConfirmed {
4715 ssa_id: id1,
4716 amount: sufficient_balance(),
4717 },
4718 now,
4719 0,
4720 );
4721
4722 sup.next_ssa_index = u32::MAX;
4724
4725 let actions = sup.handle_event(&SessionPixEvent::AlmostRecovered(id1), now, 0);
4726 assert!(
4727 matches!(
4728 actions.as_slice(),
4729 [SessionPixAction::Close(SessionPixCloseReason::InvalidTransition)]
4730 ),
4731 "expected a single Close, got {actions:?}"
4732 );
4733 assert!(sup.closed);
4734 assert_eq!(
4735 sup.ssas.len(),
4736 1,
4737 "an index that could not be allocated must not leave a record behind"
4738 );
4739 }
4740
4741 #[test]
4746 fn request_failure_result_closes() {
4747 let p = pseudonym();
4748 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
4749 let now = Instant::now();
4750
4751 let actions = sup.action_result(
4752 &SessionPixAction::RequestSsa {
4753 ssa_ids: vec![ssa_id(p, 1)],
4754 params: dims(10, 5),
4755 },
4756 false,
4757 now,
4758 );
4759 assert_eq!(actions.len(), 1);
4760 assert!(matches!(
4761 actions[0],
4762 SessionPixAction::Close(SessionPixCloseReason::SupervisorUnavailable)
4763 ));
4764 }
4765
4766 #[test]
4771 fn tombstone_expiry_clears_recovered_ssa() {
4772 let mut cfg = default_cfg();
4773 cfg.tombstone_retention_window = Duration::from_secs(10);
4774 let p = pseudonym();
4775 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
4776 let start = Instant::now();
4777 let id = ssa_id(p, 1);
4778
4779 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
4780 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
4781 sup.handle_event(
4782 &SessionPixEvent::DepositConfirmed {
4783 ssa_id: id,
4784 amount: sufficient_balance(),
4785 },
4786 start,
4787 0,
4788 );
4789 sup.handle_event(&SessionPixEvent::Recovered(id), start, 0);
4791 assert_eq!(sup.ssas.len(), 2, "the recovered SSA's tombstone plus its successor");
4792
4793 let actions = sup.handle_deadline(start + Duration::from_secs(5), 0);
4795 assert!(actions.is_empty());
4796 assert_eq!(sup.ssas.len(), 2);
4797
4798 let actions = sup.handle_deadline(start + Duration::from_secs(11), 5);
4802 assert_eq!(actions.len(), 1, "expected RetireSsa alone, got {actions:?}");
4803 assert!(
4804 matches!(&actions[0], SessionPixAction::RetireSsa(rid) if *rid == id),
4805 "action should be RetireSsa({id}), got {:?}",
4806 actions[0]
4807 );
4808 assert_eq!(
4809 sup.ssas.len(),
4810 1,
4811 "the successor must survive its predecessor's retirement"
4812 );
4813
4814 let seen = sup.dims.target_useful_shares() + 1;
4815 let actions = sup.handle_event(
4816 &SessionPixEvent::RecoveryProgress(make_recovered_tail_progress(&sup, id, seen)),
4817 start + Duration::from_secs(12),
4818 6,
4819 );
4820 assert!(
4821 actions
4822 .iter()
4823 .any(|action| matches!(action, SessionPixAction::ProgressNotification)),
4824 "short tombstone cleanup must not truncate a longer paid FIFO drain: {actions:?}"
4825 );
4826 }
4827
4828 #[test]
4829 fn all_ssas_terminal_closes_session_after_multi_ssa_close() {
4830 let p = pseudonym();
4831 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
4832 let start = Instant::now();
4833 let id1 = ssa_id(p, 1);
4834
4835 sup.handle_event(&SessionPixEvent::SsaRequestSent(id1), start, 0);
4837 sup.handle_event(&SessionPixEvent::CommitmentVerified(id1), start, 0);
4838 sup.handle_event(
4839 &SessionPixEvent::DepositConfirmed {
4840 ssa_id: id1,
4841 amount: sufficient_balance(),
4842 },
4843 start,
4844 0,
4845 );
4846 let actions = sup.handle_event(&SessionPixEvent::AlmostRecovered(id1), start, 0);
4848 assert_eq!(actions.len(), 1);
4849 assert!(matches!(actions[0], SessionPixAction::RequestSsa { .. }));
4850 sup.handle_event(&SessionPixEvent::SsaRequestSent(ssa_id(p, 2)), start, 0);
4851
4852 let actions = sup.handle_deadline(start + Duration::from_secs(7200), 5);
4856 assert_eq!(actions.len(), 2, "expected RetireSsa(id1) + Close(RecoveryDeadline)");
4857 assert!(
4858 matches!(&actions[0], SessionPixAction::RetireSsa(rid) if *rid == id1),
4859 "first action should be RetireSsa({id1}), got {:?}",
4860 actions[0]
4861 );
4862 assert!(matches!(
4864 actions[1],
4865 SessionPixAction::Close(SessionPixCloseReason::RecoveryDeadline)
4866 ));
4867 assert!(sup.closed);
4868 }
4869
4870 #[test]
4871 fn next_deadline_none_when_after_tombstone_expiry() {
4872 let mut cfg = default_cfg();
4873 cfg.tombstone_retention_window = Duration::from_secs(10);
4874 let p = pseudonym();
4875 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, Instant::now());
4876 let start = Instant::now();
4877 let id = ssa_id(p, 1);
4878
4879 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
4880 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
4881 sup.handle_event(
4882 &SessionPixEvent::DepositConfirmed {
4883 ssa_id: id,
4884 amount: sufficient_balance(),
4885 },
4886 start,
4887 0,
4888 );
4889 sup.handle_event(&SessionPixEvent::Recovered(id), start, 0);
4890
4891 let max_seen = max_cycle_shares(&sup);
4893 sup.handle_event(
4894 &SessionPixEvent::RecoveryProgress(make_recovered_tail_progress(&sup, id, max_seen)),
4895 start,
4896 max_seen,
4897 );
4898
4899 assert!(sup.next_deadline().is_some());
4901
4902 sup.handle_deadline(start + Duration::from_secs(11), 0);
4904
4905 assert!(sup.next_deadline().is_none());
4906 }
4907
4908 #[test]
4909 fn action_result_close_sets_closed() {
4910 let p = pseudonym();
4911 let (mut sup, _actions) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
4912 let now = Instant::now();
4913 assert!(!sup.closed);
4914
4915 let _ = sup.action_result(&SessionPixAction::Close(SessionPixCloseReason::RecoveryIdle), true, now);
4917 assert!(sup.closed);
4918
4919 let actions = sup.handle_deadline(now, 0);
4921 assert!(actions.is_empty());
4922 let actions = sup.handle_event(&SessionPixEvent::SsaRequestSent(ssa_id(p, 1)), now, 0);
4923 assert!(actions.is_empty());
4924 }
4925
4926 #[test]
4931 fn recovered_before_commitment_is_ignored() {
4932 let p = pseudonym();
4933 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
4934 let now = Instant::now();
4935 let id = ssa_id(p, 1);
4936
4937 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
4938
4939 let actions = sup.handle_event(&SessionPixEvent::Recovered(id), now, 0);
4941 assert!(actions.is_empty(), "recovered before commitment should be ignored");
4942 assert!(!sup.closed);
4943 }
4944
4945 #[test]
4946 fn recovered_before_deposit_is_ignored() {
4947 let p = pseudonym();
4948 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
4949 let now = Instant::now();
4950 let id = ssa_id(p, 1);
4951
4952 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), now, 0);
4953 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), now, 0);
4954
4955 let actions = sup.handle_event(&SessionPixEvent::Recovered(id), now, 0);
4957 assert!(actions.is_empty(), "recovered before deposit should be ignored");
4958 }
4959
4960 #[test]
4967 fn recovered_before_deposit_then_funded_session_survives() {
4968 let p = pseudonym();
4969 let start = Instant::now();
4970 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(2, 2), p, start);
4971 let id = ssa_id(p, 1);
4972
4973 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
4974 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
4975
4976 sup.handle_event(&SessionPixEvent::Recovered(id), start, 0);
4979
4980 let actions = sup.handle_event(
4982 &SessionPixEvent::DepositConfirmed {
4983 ssa_id: id,
4984 amount: sufficient_balance(),
4985 },
4986 start,
4987 10,
4988 );
4989 assert!(!sup.closed, "session must be alive after funding");
4990
4991 let ssa1_phase = sup.ssas.iter().find(|s| s.ssa_id == id).map(|s| &s.phase);
4993 assert!(
4994 matches!(ssa1_phase, Some(SsaPhase::Recovered { .. })),
4995 "SSA 1 should be tombstoned, got {ssa1_phase:?}"
4996 );
4997
4998 let next_id = ssa_id(p, 2);
5001 assert!(
5002 actions
5003 .iter()
5004 .any(|a| matches!(a, SessionPixAction::RequestSsa { ssa_ids, .. } if ssa_ids.contains(&next_id))),
5005 "expected RequestSsa for SSA 2, got actions: {actions:?}"
5006 );
5007
5008 let deadline_actions = sup.handle_deadline(start + Duration::from_secs(61), 10);
5012 assert!(
5013 !deadline_actions
5014 .iter()
5015 .any(|a| matches!(a, SessionPixAction::Close(SessionPixCloseReason::RecoveryIdle))),
5016 "must not close with RecoveryIdle, got: {deadline_actions:?}"
5017 );
5018 }
5019
5020 #[test]
5021 fn the_immediate_recovered_tail_reports_bounded_liveness() {
5022 let p = pseudonym();
5023 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
5024 let start = Instant::now();
5025 let id = ssa_id(p, 1);
5026
5027 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
5028 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
5029 sup.handle_event(
5030 &SessionPixEvent::DepositConfirmed {
5031 ssa_id: id,
5032 amount: sufficient_balance(),
5033 },
5034 start,
5035 0,
5036 );
5037 sup.handle_event(&SessionPixEvent::Recovered(id), start, 0);
5038
5039 let seen = sup.dims.target_useful_shares() + 1;
5040 let actions = sup.handle_event(
5041 &SessionPixEvent::RecoveryProgress(make_recovered_tail_progress(&sup, id, seen)),
5042 start,
5043 100,
5044 );
5045 assert!(
5046 actions
5047 .iter()
5048 .any(|action| matches!(action, SessionPixAction::ProgressNotification)),
5049 "the authorized predecessor must keep the gate alive while its FIFO tail drains: {actions:?}"
5050 );
5051 }
5052
5053 #[test]
5057 fn a_pre_recovery_snapshot_overtaken_by_recovered_is_absorbed() {
5058 let mut cfg = default_cfg();
5059 cfg.ssas_per_request = 2;
5060 let p = pseudonym();
5061 let start = Instant::now();
5062 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, start);
5063 let recovered = ssa_id(p, 1);
5064 fund_batch(&mut sup, p, 2, start);
5065 sup.handle_event(&SessionPixEvent::Recovered(recovered), start, 0);
5066
5067 let target = sup.dims.target_useful_shares();
5068 let stale = SsaRecoveryProgress {
5069 ssa_id: recovered,
5070 useful_shares: target - 1,
5071 shares_seen: target + 1,
5072 target_useful_shares: target,
5073 recovered_polynomials: sup.dims.polys_per_ssa() - 1,
5074 };
5075 let actions = sup.handle_event(&SessionPixEvent::RecoveryProgress(stale), start, 1);
5076 assert!(
5077 actions.is_empty(),
5078 "an overtaken non-terminal snapshot must be stale noise, not liveness or a close: {actions:?}"
5079 );
5080 assert_eq!(
5081 sup.paid_recovery_tail.as_ref().map(|tail| tail.largest_shares_seen),
5082 Some(target),
5083 "the stale snapshot must not consume any paid tail"
5084 );
5085
5086 let actions = sup.handle_event(
5087 &SessionPixEvent::RecoveryProgress(make_recovered_tail_progress(&sup, recovered, target + 2)),
5088 start,
5089 2,
5090 );
5091 assert!(
5092 actions
5093 .iter()
5094 .any(|action| matches!(action, SessionPixAction::ProgressNotification)),
5095 "a later terminal snapshot must still consume the legitimate tail: {actions:?}"
5096 );
5097 }
5098
5099 #[test]
5103 fn recovery_refreshes_the_tail_idle_clock_once() {
5104 let mut cfg = default_cfg();
5105 cfg.ssas_per_request = 2;
5106 cfg.max_recovery_idle = Duration::from_secs(10);
5107 let p = pseudonym();
5108 let start = Instant::now();
5109 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, start);
5110 let recovered = ssa_id(p, 1);
5111 fund_batch(&mut sup, p, 2, start);
5112
5113 let recovered_at = start + Duration::from_secs(9);
5114 sup.handle_event(&SessionPixEvent::Recovered(recovered), recovered_at, 5);
5115 let actions = sup.handle_deadline(start + Duration::from_secs(11), 6);
5116 assert!(
5117 actions
5118 .iter()
5119 .all(|action| !matches!(action, SessionPixAction::Close(SessionPixCloseReason::RecoveryIdle))),
5120 "the pre-recovery idle instant must not expire a freshly recovered tail: {actions:?}"
5121 );
5122
5123 let actions = sup.handle_deadline(start + Duration::from_secs(20), 6);
5124 assert!(
5125 matches!(
5126 actions.as_slice(),
5127 [SessionPixAction::Close(SessionPixCloseReason::RecoveryIdle)]
5128 ),
5129 "the one-time refresh must not weaken the ordinary stalled-tail bound: {actions:?}"
5130 );
5131 }
5132
5133 #[test]
5135 fn a_recovered_tail_cannot_report_more_service_than_the_entry_paid_for() {
5136 let mut cfg = default_cfg();
5137 cfg.ssas_per_request = 2;
5138 let p = pseudonym();
5139 let start = Instant::now();
5140 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, start);
5141 let recovered = ssa_id(p, 1);
5142 fund_batch(&mut sup, p, 2, start);
5143 sup.handle_event(&SessionPixEvent::Recovered(recovered), start, 0);
5144
5145 let impossible = max_cycle_shares(&sup) + 1;
5146 let actions = sup.handle_event(
5147 &SessionPixEvent::RecoveryProgress(make_recovered_tail_progress(&sup, recovered, impossible)),
5148 start,
5149 1,
5150 );
5151 assert!(
5152 matches!(
5153 actions.as_slice(),
5154 [SessionPixAction::Close(SessionPixCloseReason::CounterRegression)]
5155 ),
5156 "over-budget recovered progress must fail closed, never become gate liveness: {actions:?}"
5157 );
5158 assert_eq!(
5159 sup.paid_recovery_tail.as_ref().map(|tail| tail.largest_shares_seen),
5160 Some(sup.dims.target_useful_shares()),
5161 "the rejected snapshot must not mutate the authorized absolute watermark"
5162 );
5163 }
5164
5165 #[test]
5167 fn a_stalled_recovered_tail_cannot_hold_the_session_open() {
5168 let mut cfg = default_cfg();
5169 cfg.ssas_per_request = 2;
5170 cfg.max_recovery_idle = Duration::from_secs(10);
5171 let p = pseudonym();
5172 let start = Instant::now();
5173 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, start);
5174 let recovered = ssa_id(p, 1);
5175 fund_batch(&mut sup, p, 2, start);
5176 sup.handle_event(&SessionPixEvent::Recovered(recovered), start, 0);
5177
5178 let actions = sup.handle_deadline(start + Duration::from_secs(11), 1);
5179 assert!(
5180 matches!(
5181 actions.as_slice(),
5182 [SessionPixAction::Close(SessionPixCloseReason::RecoveryIdle)]
5183 ),
5184 "the Entry cannot turn an unspent declared surplus into indefinite funded service: {actions:?}"
5185 );
5186 }
5187
5188 #[test]
5191 fn a_trickling_recovered_tail_cannot_extend_the_hard_deadline() {
5192 let mut cfg = default_cfg();
5193 cfg.ssas_per_request = 2;
5194 cfg.max_recovery_idle = Duration::from_secs(10);
5195 cfg.max_recovery_time = Duration::from_secs(20);
5196 let p = pseudonym();
5197 let start = Instant::now();
5198 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, start);
5199 let recovered = ssa_id(p, 1);
5200 fund_batch(&mut sup, p, 2, start);
5201 sup.handle_event(
5202 &SessionPixEvent::Recovered(recovered),
5203 start + Duration::from_secs(1),
5204 0,
5205 );
5206
5207 let target = sup.dims.target_useful_shares();
5208 for (at, seen, served) in [(9, target + 1, 1), (18, target + 2, 2)] {
5209 let actions = sup.handle_event(
5210 &SessionPixEvent::RecoveryProgress(make_recovered_tail_progress(&sup, recovered, seen)),
5211 start + Duration::from_secs(at),
5212 served,
5213 );
5214 assert!(
5215 actions
5216 .iter()
5217 .all(|action| !matches!(action, SessionPixAction::Close(_))),
5218 "legitimate tail progress before the hard limit must remain live: {actions:?}"
5219 );
5220 }
5221
5222 let actions = sup.handle_deadline(start + Duration::from_secs(21), 3);
5223 assert!(
5224 matches!(
5225 actions.as_slice(),
5226 [SessionPixAction::Close(SessionPixCloseReason::RecoveryDeadline)]
5227 ),
5228 "tail trickle must not move the predecessor's absolute deadline: {actions:?}"
5229 );
5230 assert_eq!(
5233 Some(&(PixCyclePhase::PaidTail, 20_000)),
5234 sup.take_telemetry_events()
5235 .phase_durations
5236 .iter()
5237 .find(|(phase, _)| *phase == PixCyclePhase::PaidTail)
5238 );
5239 }
5240
5241 #[test]
5243 fn an_older_recovered_cycle_cannot_reopen_service() {
5244 let mut cfg = default_cfg();
5245 cfg.ssas_per_request = 2;
5246 cfg.max_off_front_share_fraction = 1.0;
5247 let p = pseudonym();
5248 let start = Instant::now();
5249 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(10, 5), p, start);
5250 let first = ssa_id(p, 1);
5251 let second = ssa_id(p, 2);
5252 fund_batch(&mut sup, p, 2, start);
5253
5254 sup.handle_event(&SessionPixEvent::Recovered(first), start, 0);
5255 sup.handle_event(
5256 &SessionPixEvent::RecoveryProgress(make_progress(second, 1, sup.dims.target_useful_shares(), 0)),
5257 start,
5258 1,
5259 );
5260 sup.handle_event(&SessionPixEvent::Recovered(second), start, 1);
5261
5262 let actions = sup.handle_event(
5263 &SessionPixEvent::RecoveryProgress(make_recovered_tail_progress(
5264 &sup,
5265 first,
5266 sup.dims.target_useful_shares() + 1,
5267 )),
5268 start,
5269 2,
5270 );
5271 assert!(
5272 actions.is_empty(),
5273 "an obsolete recovered-cycle tag must not refresh or reopen the Exit's service gate: {actions:?}"
5274 );
5275 }
5276
5277 #[test]
5278 fn late_tombstone_unverifiable_shares_are_absorbed() {
5279 let p = pseudonym();
5280 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(10, 5), p, Instant::now());
5281 let start = Instant::now();
5282 let id = ssa_id(p, 1);
5283
5284 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), start, 0);
5285 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), start, 0);
5286 sup.handle_event(
5287 &SessionPixEvent::DepositConfirmed {
5288 ssa_id: id,
5289 amount: sufficient_balance(),
5290 },
5291 start,
5292 0,
5293 );
5294 sup.handle_event(&SessionPixEvent::Recovered(id), start, 0);
5295
5296 let actions = sup.handle_event(
5298 &SessionPixEvent::UnverifiableShares {
5299 ssa_id: id,
5300 observed_total: 5,
5301 peer: crate::supervision::test_peer(),
5302 },
5303 start,
5304 100,
5305 );
5306 assert!(
5307 actions.is_empty(),
5308 "late tombstone unverifiable shares should be absorbed"
5309 );
5310 }
5311
5312 fn fill_dims() -> PixParams {
5323 dims(2048, 64)
5324 }
5325
5326 fn planned_rate(actions: &[SessionPixAction]) -> Option<FillRate> {
5328 actions
5329 .iter()
5330 .filter_map(|action| match action {
5331 SessionPixAction::SetFillRate(rate) => Some(*rate),
5332 _ => None,
5333 })
5334 .next_back()
5335 }
5336
5337 fn funded_at_front(cfg: SupervisorConfig, dims: PixParams, now: Instant) -> (SessionPixSupervisor, HoprPseudonym) {
5342 let p = pseudonym();
5343 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims, p, now);
5344 fund_batch(&mut sup, p, 1, now);
5345 (sup, p)
5346 }
5347
5348 fn feed_seen(sup: &mut SessionPixSupervisor, id: SsaId<HoprPseudonym>, seen: u64, now: Instant, served: u64) {
5350 let target = sup.dims.target_useful_shares();
5351 let progress = make_progress_seen(id, seen.min(target), seen, target, 0);
5352 sup.handle_event(&SessionPixEvent::RecoveryProgress(progress), now, served);
5353 }
5354
5355 #[test]
5363 fn no_fill_is_planned_or_ticked_before_a_cycle_is_funded() {
5364 let t0 = Instant::now();
5365 let p = pseudonym();
5366 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), fill_dims(), p, t0);
5367
5368 assert_eq!(None, sup.next_fill_tick(), "a fresh Session has nothing to fill for");
5369
5370 commit_unfunded(&mut sup, p, 1..=1, t0);
5371 assert_eq!(
5372 None,
5373 sup.next_fill_tick(),
5374 "a committed but unfunded cycle still has nothing at stake"
5375 );
5376
5377 let actions = sup.handle_timers(t0 + Duration::from_secs(2), 0);
5379 assert_eq!(None, planned_rate(&actions), "an unfunded cycle must not be filled");
5380 }
5381
5382 #[test]
5390 fn a_recovered_cycle_without_a_tail_stops_fill_and_plans_nothing_for_its_successor() {
5391 let t0 = Instant::now();
5392 let (mut sup, p) = funded_at_front(default_cfg(), fill_dims(), t0);
5393 let id = ssa_id(p, 1);
5394
5395 let running = planned_rate(&sup.handle_timers(t0 + SAMPLING_INTERVAL, 0))
5396 .expect("a funded cycle at the front must be filled");
5397 assert!(!running.is_zero());
5398
5399 let whole_cycle = max_cycle_shares(&sup);
5401 feed_seen(&mut sup, id, whole_cycle, t0 + Duration::from_secs(2), 0);
5402 let actions = sup.handle_event(&SessionPixEvent::Recovered(id), t0 + Duration::from_secs(2), 0);
5403 assert_eq!(
5404 Some(FillRate::ZERO),
5405 planned_rate(&actions),
5406 "recovery without a tail must silence fill in the same action vector"
5407 );
5408
5409 assert_eq!(
5410 None,
5411 sup.next_fill_tick(),
5412 "an unfunded successor is not something to wake up for"
5413 );
5414 let actions = sup.handle_timers(t0 + Duration::from_secs(4), 0);
5415 assert_eq!(None, planned_rate(&actions), "and nothing is planned for it either");
5416 }
5417
5418 #[test]
5429 fn an_idle_funded_cycle_is_filled_to_completion_before_its_aim_point() {
5430 let cfg = default_cfg();
5431 let t0 = Instant::now();
5432 let (mut sup, p) = funded_at_front(cfg.clone(), fill_dims(), t0);
5433 let id = ssa_id(p, 1);
5434
5435 let cycle = max_cycle_shares(&sup);
5436 let aim_point = cfg.max_recovery_time.mul_f64(cfg.fill.finish_fraction);
5437
5438 let mut rate = FillRate::ZERO;
5439 let mut sent = 0.0_f64;
5440 let mut completed_at = None;
5441 for second in 1..=aim_point.as_secs() {
5442 let now = t0 + Duration::from_secs(second);
5443 if let Some(planned) = planned_rate(&sup.handle_timers(now, 0)) {
5444 assert!(
5445 planned.as_packets_per_sec() <= cfg.fill.max_rate as f64,
5446 "fill asked for {} packets/s against a ceiling of {}",
5447 planned.as_packets_per_sec(),
5448 cfg.fill.max_rate
5449 );
5450 rate = planned;
5451 }
5452
5453 sent += rate.as_packets_per_sec();
5455 if sent as u64 >= cycle {
5456 completed_at = Some(second);
5457 break;
5458 }
5459 feed_seen(&mut sup, id, sent as u64, now, 0);
5460 }
5461
5462 let completed_at = completed_at.unwrap_or_else(|| {
5463 panic!("an idle cycle of {cycle} packets was not filled to completion within {aim_point:?}")
5464 });
5465 assert!(
5466 Duration::from_secs(completed_at) < aim_point,
5467 "the cycle completed at {completed_at}s, which is not inside the {aim_point:?} aim point"
5468 );
5469 }
5470
5471 #[test]
5478 fn organic_traffic_above_the_requirement_holds_fill_at_the_heartbeat() {
5479 let cfg = default_cfg();
5480 let t0 = Instant::now();
5481 let (mut sup, p) = funded_at_front(cfg.clone(), fill_dims(), t0);
5482 let id = ssa_id(p, 1);
5483
5484 const ORGANIC: u64 = 200;
5487 let mut rate = None;
5488 for second in 1..=(4 * ORGANIC_WINDOW.as_secs()) {
5489 let now = t0 + Duration::from_secs(second);
5490 let served = ORGANIC * second;
5491 if let Some(planned) = planned_rate(&sup.handle_timers(now, served)) {
5492 rate = Some(planned);
5493 }
5494 feed_seen(&mut sup, id, served, now, served);
5495 }
5496
5497 assert_eq!(
5498 Some(FillRate::once_per(cfg.fill.heartbeat)),
5499 rate,
5500 "an application outrunning the requirement must leave fill at its heartbeat"
5501 );
5502 }
5503
5504 #[test]
5512 fn fill_resumes_from_the_true_remainder_when_organic_traffic_stops() {
5513 let cfg = default_cfg();
5514 let t0 = Instant::now();
5515 let (mut sup, p) = funded_at_front(cfg.clone(), fill_dims(), t0);
5516 let id = ssa_id(p, 1);
5517
5518 const ORGANIC: u64 = 200;
5519 const BUSY_FOR: u64 = 300;
5520 let mut seen = 0;
5521 for second in 1..=BUSY_FOR {
5522 let now = t0 + Duration::from_secs(second);
5523 seen = ORGANIC * second;
5524 sup.handle_timers(now, seen);
5525 feed_seen(&mut sup, id, seen, now, seen);
5526 }
5527
5528 let mut rate = None;
5530 for second in (BUSY_FOR + 1)..=(BUSY_FOR + 4 * ORGANIC_WINDOW.as_secs()) {
5531 let now = t0 + Duration::from_secs(second);
5532 if let Some(planned) = planned_rate(&sup.handle_timers(now, seen)) {
5533 rate = Some(planned);
5534 }
5535 }
5536
5537 let elapsed = Duration::from_secs(BUSY_FOR + 4 * ORGANIC_WINDOW.as_secs());
5538 let horizon = cfg.max_recovery_time.mul_f64(cfg.fill.finish_fraction) - elapsed;
5539 let expected = (max_cycle_shares(&sup) - seen) as f64 * (1.0 + cfg.fill.loss_margin) / horizon.as_secs_f64();
5540 let actual = rate
5541 .expect("fill must resume once the application stops")
5542 .as_packets_per_sec();
5543 assert!(
5544 (actual - expected).abs() <= 0.15 * expected,
5545 "fill resumed at {actual} packets/s against a remainder that needs {expected}"
5546 );
5547 }
5548
5549 #[test]
5557 fn the_paid_recovery_tail_is_filled_against_its_predecessors_deadline() {
5558 let cfg = default_cfg();
5559 let t0 = Instant::now();
5560 let (mut sup, p) = funded_at_front(cfg.clone(), fill_dims(), t0);
5561 let first = ssa_id(p, 1);
5562 let hard = sup.ssas[0]
5563 .recovery_hard_deadline
5564 .expect("a funded front cycle is clocked");
5565
5566 let partial = max_cycle_shares(&sup) / 2;
5568 feed_seen(&mut sup, first, partial, t0 + Duration::from_secs(1), 0);
5569 sup.handle_event(&SessionPixEvent::Recovered(first), t0 + Duration::from_secs(1), 0);
5570
5571 let target = sup.fill_target().expect("the paid tail is the thing being filled for");
5572 assert_eq!(first, target.ssa_id, "the tail, not the successor, is the target");
5573 assert_eq!(
5574 hard, target.hard_deadline,
5575 "the tail must be planned against the clock it inherited, not a fresh one"
5576 );
5577
5578 let second = ssa_id(p, 2);
5581 sup.handle_event(&SessionPixEvent::SsaRequestSent(second), t0 + Duration::from_secs(2), 0);
5582 sup.handle_event(
5583 &SessionPixEvent::CommitmentVerified(second),
5584 t0 + Duration::from_secs(2),
5585 0,
5586 );
5587 sup.handle_event(
5588 &SessionPixEvent::DepositConfirmed {
5589 ssa_id: second,
5590 amount: sufficient_balance(),
5591 },
5592 t0 + Duration::from_secs(2),
5593 0,
5594 );
5595 assert_eq!(
5596 Some(first),
5597 sup.fill_target().map(|target| target.ssa_id),
5598 "a funded successor does not take the front while the tail is still draining"
5599 );
5600
5601 feed_seen(&mut sup, second, 1, t0 + Duration::from_secs(3), 0);
5602 let target = sup.fill_target().expect("the successor is now the front");
5603 assert_eq!(second, target.ssa_id);
5604 assert!(
5605 target.hard_deadline > hard,
5606 "the successor's clock starts at the FIFO boundary, so it must be later than its predecessor's"
5607 );
5608 }
5609
5610 #[test]
5616 fn only_the_front_cycle_of_a_batch_is_filled_for() {
5617 let cfg = SupervisorConfig {
5618 ssas_per_request: 2,
5619 ..default_cfg()
5620 };
5621 let t0 = Instant::now();
5622 let p = pseudonym();
5623 let (mut sup, _) = SessionPixSupervisor::new(cfg, fill_dims(), p, t0);
5624 fund_batch(&mut sup, p, 2, t0);
5625
5626 assert_eq!(
5627 Some(ssa_id(p, 1)),
5628 sup.fill_target().map(|target| target.ssa_id),
5629 "the second cycle of a batch is queued, not being served"
5630 );
5631 }
5632
5633 #[test]
5644 fn a_stalled_cycle_falls_back_to_the_heartbeat_and_recovers_when_it_moves() {
5645 let cfg = default_cfg();
5646 let t0 = Instant::now();
5647 let (mut sup, p) = funded_at_front(cfg.clone(), fill_dims(), t0);
5648 let id = ssa_id(p, 1);
5649
5650 let running = planned_rate(&sup.handle_timers(t0 + SAMPLING_INTERVAL, 0)).expect("fill must start");
5651 assert!(
5652 running.as_packets_per_sec() > FillRate::once_per(cfg.fill.heartbeat).as_packets_per_sec(),
5653 "the fixture needs a rate above the heartbeat for the fallback to be observable"
5654 );
5655
5656 let mut rate = None;
5658 for second in 2..=(cfg.max_recovery_idle.as_secs() + 2) {
5659 if let Some(planned) = planned_rate(&sup.handle_timers(t0 + Duration::from_secs(second), 0)) {
5660 rate = Some(planned);
5661 }
5662 }
5663 assert_eq!(
5664 Some(FillRate::once_per(cfg.fill.heartbeat)),
5665 rate,
5666 "a motionless cycle must be held at the heartbeat"
5667 );
5668
5669 let resumed_at = t0 + Duration::from_secs(cfg.max_recovery_idle.as_secs() + 3);
5671 feed_seen(&mut sup, id, 1, resumed_at, 0);
5672 let rate = planned_rate(&sup.handle_timers(resumed_at + SAMPLING_INTERVAL, 0))
5673 .expect("progress must lift the fallback");
5674 assert!(
5675 rate.as_packets_per_sec() > FillRate::once_per(cfg.fill.heartbeat).as_packets_per_sec(),
5676 "a cycle that resumed must be filled again, got {rate:?}"
5677 );
5678 }
5679
5680 #[test]
5687 fn a_rate_that_barely_moved_is_not_re_emitted() {
5688 let t0 = Instant::now();
5689 let (mut sup, _) = funded_at_front(default_cfg(), fill_dims(), t0);
5690
5691 assert!(
5692 planned_rate(&sup.handle_timers(t0 + SAMPLING_INTERVAL, 0)).is_some(),
5693 "the first plan is always a change"
5694 );
5695 for second in 2..=10 {
5696 assert_eq!(
5697 None,
5698 planned_rate(&sup.handle_timers(t0 + Duration::from_secs(second), 0)),
5699 "second {second} re-emitted a rate that had not meaningfully changed"
5700 );
5701 }
5702 }
5703
5704 #[test]
5711 fn a_close_silences_fill_before_it_is_delivered() {
5712 let t0 = Instant::now();
5713 let (mut sup, p) = funded_at_front(default_cfg(), fill_dims(), t0);
5714 assert!(planned_rate(&sup.handle_timers(t0 + SAMPLING_INTERVAL, 0)).is_some());
5715
5716 let actions = sup.handle_event(
5717 &SessionPixEvent::UnverifiableShares {
5718 ssa_id: ssa_id(p, 1),
5719 observed_total: 1,
5720 peer: crate::supervision::test_peer(),
5721 },
5722 t0 + Duration::from_secs(2),
5723 0,
5724 );
5725 assert!(
5726 matches!(actions.first(), Some(SessionPixAction::SetFillRate(rate)) if rate.is_zero()),
5727 "the zero must lead the close, got {actions:?}"
5728 );
5729 assert!(
5730 actions.iter().any(|action| matches!(
5731 action,
5732 SessionPixAction::Close(SessionPixCloseReason::UnverifiableShares)
5733 )),
5734 "the close itself must still be there, got {actions:?}"
5735 );
5736 }
5737
5738 #[test]
5744 fn retiring_past_the_failure_budget_silences_fill() {
5745 const BATCH: u32 = 4;
5746
5747 let cfg = SupervisorConfig {
5748 ssas_per_request: BATCH as usize,
5749 max_failed_cycles: 1,
5750 ..default_cfg()
5751 };
5752 let t0 = Instant::now();
5753 let p = pseudonym();
5754 let (mut sup, _) = SessionPixSupervisor::new(cfg, fill_dims(), p, t0);
5755
5756 fund_batch(&mut sup, p, 2, t0);
5759 commit_unfunded(&mut sup, p, 3..=BATCH, t0);
5760 assert!(
5761 planned_rate(&sup.handle_timers(t0 + SAMPLING_INTERVAL, 0)).is_some(),
5762 "the funded front must be filling before the losses"
5763 );
5764
5765 sup.handle_event(&SessionPixEvent::DepositObserverClosed(ssa_id(p, 3)), t0, 0);
5768 assert!(!sup.closed, "one loss is inside the limit");
5769 let actions = sup.handle_event(&SessionPixEvent::DepositObserverClosed(ssa_id(p, BATCH)), t0, 0);
5770
5771 assert!(
5772 actions
5773 .iter()
5774 .any(|action| matches!(action, SessionPixAction::Close(_))),
5775 "the second lost cycle must close the Session, got {actions:?}"
5776 );
5777 assert!(
5778 matches!(actions.first(), Some(SessionPixAction::SetFillRate(rate)) if rate.is_zero()),
5779 "the zero must lead the close, got {actions:?}"
5780 );
5781 }
5782
5783 #[test]
5795 fn a_closed_session_with_enough_surbs_drains_its_funded_cycle() {
5796 let cfg = default_cfg();
5797 let t0 = Instant::now();
5798 let (mut sup, _) = funded_at_front(cfg.clone(), fill_dims(), t0);
5799
5800 let actions = sup.handle_event(
5801 &SessionPixEvent::SessionClosed {
5802 drainable_surbs: max_cycle_shares(&sup),
5803 },
5804 t0 + Duration::from_secs(1),
5805 0,
5806 );
5807
5808 assert!(!sup.closed, "a drain leaves the state machine running");
5809 assert!(sup.draining);
5810 assert!(
5811 !actions
5812 .iter()
5813 .any(|action| matches!(action, SessionPixAction::Close(_) | SessionPixAction::RequestSsa { .. })),
5814 "a drain neither closes the Session nor asks for a successor, got {actions:?}"
5815 );
5816
5817 let rate =
5819 planned_rate(&sup.handle_timers(t0 + Duration::from_secs(2), 0)).expect("a draining cycle must be filled");
5820 assert_eq!(FillRate::per_second(cfg.fill.max_rate), rate);
5821 }
5822
5823 #[test]
5829 fn a_closed_session_without_enough_surbs_closes_instead_of_draining() {
5830 let t0 = Instant::now();
5831 let (mut sup, p) = funded_at_front(default_cfg(), fill_dims(), t0);
5832 let id = ssa_id(p, 1);
5833
5834 assert!(planned_rate(&sup.handle_timers(t0 + SAMPLING_INTERVAL, 0)).is_some());
5836
5837 let seen = max_cycle_shares(&sup) / 2;
5838 feed_seen(&mut sup, id, seen, t0 + Duration::from_secs(2), 0);
5839 let one_short = max_cycle_shares(&sup) - seen - 1;
5840
5841 let actions = sup.handle_event(
5842 &SessionPixEvent::SessionClosed {
5843 drainable_surbs: one_short,
5844 },
5845 t0 + Duration::from_secs(3),
5846 0,
5847 );
5848
5849 assert!(sup.closed);
5850 assert!(!sup.draining);
5851 assert!(
5852 matches!(actions.first(), Some(SessionPixAction::SetFillRate(rate)) if rate.is_zero()),
5853 "the zero must lead the close, got {actions:?}"
5854 );
5855 assert!(
5856 actions
5857 .iter()
5858 .any(|action| matches!(action, SessionPixAction::Close(SessionPixCloseReason::SessionClosed))),
5859 "got {actions:?}"
5860 );
5861 }
5862
5863 #[test]
5870 fn a_closed_session_with_no_funded_cycle_closes_instead_of_draining() {
5871 let plenty = u64::MAX;
5872
5873 let t0 = Instant::now();
5874 let p = pseudonym();
5875 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), fill_dims(), p, t0);
5876 commit_unfunded(&mut sup, p, 1..=1, t0);
5877
5878 let actions = sup.handle_event(
5879 &SessionPixEvent::SessionClosed {
5880 drainable_surbs: plenty,
5881 },
5882 t0 + Duration::from_secs(1),
5883 0,
5884 );
5885 assert!(
5886 sup.closed && !sup.draining,
5887 "an unfunded front has nothing to drain for"
5888 );
5889 assert!(
5890 actions
5891 .iter()
5892 .any(|action| matches!(action, SessionPixAction::Close(SessionPixCloseReason::SessionClosed))),
5893 "got {actions:?}"
5894 );
5895
5896 let t0 = Instant::now();
5898 let (mut sup, p) = funded_at_front(default_cfg(), fill_dims(), t0);
5899 let first = ssa_id(p, 1);
5900 let half = max_cycle_shares(&sup) / 2;
5901 feed_seen(&mut sup, first, half, t0 + Duration::from_secs(1), 0);
5902 sup.handle_event(&SessionPixEvent::Recovered(first), t0 + Duration::from_secs(1), 0);
5903 assert!(
5904 sup.paid_recovery_tail.is_some(),
5905 "the fixture must leave a paid tail behind for this half to mean anything"
5906 );
5907
5908 let actions = sup.handle_event(
5909 &SessionPixEvent::SessionClosed {
5910 drainable_surbs: plenty,
5911 },
5912 t0 + Duration::from_secs(2),
5913 0,
5914 );
5915 assert!(sup.closed && !sup.draining, "a recovered key is not worth draining for");
5916 assert!(
5917 actions
5918 .iter()
5919 .any(|action| matches!(action, SessionPixAction::Close(SessionPixCloseReason::SessionClosed))),
5920 "got {actions:?}"
5921 );
5922 }
5923
5924 #[test]
5933 fn a_drain_retires_every_cycle_but_the_one_it_is_recovering() {
5934 const BATCH: u32 = 4;
5935
5936 let cfg = SupervisorConfig {
5937 ssas_per_request: BATCH as usize,
5938 ..default_cfg()
5939 };
5940 let t0 = Instant::now();
5941 let p = pseudonym();
5942 let (mut sup, _) = SessionPixSupervisor::new(cfg, fill_dims(), p, t0);
5943 fund_batch(&mut sup, p, 2, t0);
5944 commit_unfunded(&mut sup, p, 3..=BATCH, t0);
5945
5946 let actions = sup.handle_event(
5947 &SessionPixEvent::SessionClosed {
5948 drainable_surbs: max_cycle_shares(&sup),
5949 },
5950 t0 + Duration::from_secs(1),
5951 0,
5952 );
5953
5954 let retired = actions
5955 .iter()
5956 .filter_map(|action| match action {
5957 SessionPixAction::RetireSsa(id) => Some(id.ssa_index().get()),
5958 _ => None,
5959 })
5960 .collect::<Vec<_>>();
5961 assert_eq!(
5962 vec![2, 3, 4],
5963 retired,
5964 "every cycle but the one being drained must be retired, got {actions:?}"
5965 );
5966 assert!(!sup.closed && sup.draining);
5967 assert_eq!(0, sup.failed_cycles, "a drain retires siblings, it does not lose them");
5968 assert_eq!(1, sup.ssas.len());
5969 assert_eq!(ssa_id(p, 1), sup.ssas[0].ssa_id);
5970 }
5971
5972 #[test]
5980 fn a_completed_drain_closes_the_session_without_asking_for_a_successor() {
5981 let t0 = Instant::now();
5982 let (mut sup, p) = funded_at_front(default_cfg(), fill_dims(), t0);
5983 let id = ssa_id(p, 1);
5984 assert!(planned_rate(&sup.handle_timers(t0 + SAMPLING_INTERVAL, 0)).is_some());
5985
5986 sup.handle_event(
5987 &SessionPixEvent::SessionClosed {
5988 drainable_surbs: max_cycle_shares(&sup),
5989 },
5990 t0 + Duration::from_secs(2),
5991 0,
5992 );
5993 assert!(sup.draining);
5994
5995 let early = sup.handle_event(&SessionPixEvent::AlmostRecovered(id), t0 + Duration::from_secs(3), 0);
5996 assert!(
5997 early.is_empty(),
5998 "the early-recovery signal must not order a successor for a Session that is gone, got {early:?}"
5999 );
6000
6001 let actions = sup.handle_event(&SessionPixEvent::Recovered(id), t0 + Duration::from_secs(4), 0);
6002 assert!(sup.closed);
6003 assert!(
6004 matches!(actions.first(), Some(SessionPixAction::SetFillRate(rate)) if rate.is_zero()),
6005 "the zero must lead the close, got {actions:?}"
6006 );
6007 assert!(
6008 actions
6009 .iter()
6010 .any(|action| matches!(action, SessionPixAction::Close(SessionPixCloseReason::Drained))),
6011 "got {actions:?}"
6012 );
6013 assert!(
6014 !actions
6015 .iter()
6016 .any(|action| matches!(action, SessionPixAction::RequestSsa { .. })),
6017 "got {actions:?}"
6018 );
6019 }
6020
6021 #[test]
6027 fn a_completed_drain_books_the_deposit_it_unlocked_as_recovered() {
6028 let t0 = Instant::now();
6029 let (mut sup, p) = funded_at_front(default_cfg(), fill_dims(), t0);
6030 let id = ssa_id(p, 1);
6031 assert_eq!(1_000_000_000, sup.take_telemetry_events().deposit_confirmed_uhopr);
6033
6034 sup.handle_event(
6035 &SessionPixEvent::SessionClosed {
6036 drainable_surbs: max_cycle_shares(&sup),
6037 },
6038 t0 + Duration::from_secs(2),
6039 0,
6040 );
6041 sup.handle_event(&SessionPixEvent::Recovered(id), t0 + Duration::from_secs(4), 0);
6042
6043 let events = sup.take_telemetry_events();
6044 assert_eq!(
6045 1, events.recovered,
6046 "a drained cycle recovered, so it counts as recovered"
6047 );
6048 let recovered: Vec<_> = events
6049 .finalized
6050 .iter()
6051 .filter(|summary| summary.outcome == PixCycleOutcome::Recovered)
6052 .collect();
6053 assert_eq!(
6054 1,
6055 recovered.len(),
6056 "the drain must summarize its cycle, or the value it unlocked reads as stranded"
6057 );
6058 assert_eq!(1_000_000_000, recovered[0].deposit_uhopr);
6059 }
6060
6061 #[test]
6069 fn a_stalled_drain_closes_on_the_recovery_idle_deadline() {
6070 let cfg = default_cfg();
6071 let t0 = Instant::now();
6072 let (mut sup, _) = funded_at_front(cfg.clone(), fill_dims(), t0);
6073
6074 sup.handle_event(
6075 &SessionPixEvent::SessionClosed {
6076 drainable_surbs: max_cycle_shares(&sup),
6077 },
6078 t0,
6079 0,
6080 );
6081 assert!(sup.draining);
6082
6083 let actions = sup.handle_deadline(t0 + cfg.max_recovery_idle, 0);
6086 assert!(sup.closed);
6087 assert!(
6088 actions
6089 .iter()
6090 .any(|action| matches!(action, SessionPixAction::Close(SessionPixCloseReason::RecoveryIdle))),
6091 "got {actions:?}"
6092 );
6093 }
6094
6095 #[test]
6102 fn a_drain_still_ends_at_its_hard_recovery_deadline() {
6103 let cfg = default_cfg();
6104 let t0 = Instant::now();
6105 let (mut sup, _) = funded_at_front(cfg.clone(), fill_dims(), t0);
6106
6107 sup.handle_event(
6108 &SessionPixEvent::SessionClosed {
6109 drainable_surbs: max_cycle_shares(&sup),
6110 },
6111 t0,
6112 0,
6113 );
6114 assert!(sup.draining);
6115
6116 let actions = sup.handle_deadline(t0 + cfg.max_recovery_time, 0);
6117 assert!(sup.closed);
6118 assert!(
6119 actions
6120 .iter()
6121 .any(|action| matches!(action, SessionPixAction::Close(SessionPixCloseReason::RecoveryDeadline))),
6122 "got {actions:?}"
6123 );
6124 }
6125
6126 #[test]
6132 fn a_disabled_filler_plans_nothing_and_schedules_nothing() {
6133 let cfg = SupervisorConfig {
6134 fill: PixFillConfig {
6135 enabled: false,
6136 ..PixFillConfig::default()
6137 },
6138 ..default_cfg()
6139 };
6140 let t0 = Instant::now();
6141 let (mut sup, _) = funded_at_front(cfg, fill_dims(), t0);
6142
6143 assert_eq!(None, sup.next_fill_tick(), "a disabled filler has no schedule");
6144 for second in 1..=5 {
6145 let actions = sup.handle_timers(t0 + Duration::from_secs(second), 0);
6146 assert_eq!(None, planned_rate(&actions), "a disabled filler must plan nothing");
6147 }
6148 }
6149
6150 fn census(sup: &SessionPixSupervisor) -> PixSessionSnapshot {
6156 sup.telemetry_snapshot(0)
6157 }
6158
6159 fn phases(
6166 gate_mode: PixGateMode,
6167 awaiting_commitment: u32,
6168 awaiting_deposit: u32,
6169 recovering: u32,
6170 ) -> PixSessionSnapshot {
6171 PixSessionSnapshot {
6172 gate_mode,
6173 awaiting_commitment,
6174 awaiting_deposit,
6175 recovering,
6176 ..Default::default()
6177 }
6178 }
6179
6180 #[test]
6186 fn a_session_walks_the_census_from_request_to_rollover() {
6187 let t0 = Instant::now();
6188 let p = pseudonym();
6189 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(64, 8), p, t0);
6190 let first = ssa_id(p, 1);
6191
6192 assert_eq!(phases(PixGateMode::Predeposit, 1, 0, 0), census(&sup));
6194 assert_eq!(
6195 PixTurnEvents {
6196 requested: 1,
6197 ..Default::default()
6198 },
6199 sup.take_telemetry_events(),
6200 "allocation is what counts as requested, not the confirmation that it was sent"
6201 );
6202 assert!(
6203 sup.take_telemetry_events().is_empty(),
6204 "the latch must drain rather than accumulate"
6205 );
6206
6207 sup.handle_event(&SessionPixEvent::SsaRequestSent(first), t0, 0);
6208 assert_eq!(phases(PixGateMode::Predeposit, 1, 0, 0), census(&sup));
6209 assert!(
6210 sup.take_telemetry_events().is_empty(),
6211 "confirming the send is not a lifecycle transition"
6212 );
6213
6214 sup.handle_event(&SessionPixEvent::CommitmentVerified(first), t0, 0);
6215 assert_eq!(phases(PixGateMode::Predeposit, 0, 1, 0), census(&sup));
6216 assert_eq!(
6217 PixTurnEvents {
6218 committed: 1,
6219 phase_durations: vec![(PixCyclePhase::AwaitingCommitment, 0)],
6223 ..Default::default()
6224 },
6225 sup.take_telemetry_events()
6226 );
6227 assert_eq!(
6228 PixGateMode::Predeposit,
6229 census(&sup).gate_mode,
6230 "a committed but unfunded front is still spending its predeposit allowance"
6231 );
6232
6233 sup.handle_event(
6234 &SessionPixEvent::DepositConfirmed {
6235 ssa_id: first,
6236 amount: sufficient_balance(),
6237 },
6238 t0,
6239 0,
6240 );
6241 assert_eq!(phases(PixGateMode::Funded, 0, 0, 1), census(&sup));
6242 assert_eq!(
6243 PixTurnEvents {
6244 funded: 1,
6245 deposit_confirmed_uhopr: 1_000_000_000,
6248 phase_durations: vec![(PixCyclePhase::AwaitingDeposit, 0)],
6249 ..Default::default()
6250 },
6251 sup.take_telemetry_events()
6252 );
6253 assert_eq!(PixGateMode::Funded, census(&sup).gate_mode);
6254
6255 sup.handle_event(&SessionPixEvent::Recovered(first), t0, 0);
6258 let after = census(&sup);
6259 assert_eq!(
6260 1,
6261 after.awaiting_commitment + after.awaiting_deposit + after.recovering,
6262 "a tombstone is retained state, not live state: {after:?}"
6263 );
6264 assert_eq!(0, after.recovering, "the recovered cycle must leave the live census");
6265
6266 let events = sup.take_telemetry_events();
6267 assert_eq!(
6268 (1, 1),
6269 (events.recovered, events.requested),
6270 "the successor is requested in the same turn the predecessor recovers"
6271 );
6272 assert_eq!(
6273 vec![PixCycleSummary {
6274 outcome: PixCycleOutcome::Recovered,
6275 egress_packets: 0,
6278 accepted_shares: sup.dims.target_useful_shares(),
6279 useful_shares: sup.dims.target_useful_shares(),
6280 target_useful_shares: sup.dims.target_useful_shares(),
6281 deposit_uhopr: 1_000_000_000,
6284 }],
6285 events.finalized,
6286 "a recovered cycle is summarized exactly once, at full useful coverage"
6287 );
6288 assert_eq!(
6289 Some(1.0),
6290 events.finalized[0].useful_fraction(),
6291 "a recovered cycle's useful fraction is one by construction"
6292 );
6293 }
6294
6295 #[test]
6300 fn a_paid_recovered_predecessor_is_censused_as_a_paid_tail() {
6301 let t0 = Instant::now();
6302 let p = pseudonym();
6303 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(64, 8), p, t0);
6304 let first = ssa_id(p, 1);
6305
6306 fund_batch(&mut sup, p, 1, t0);
6307 sup.handle_event(
6309 &SessionPixEvent::RecoveryProgress(make_progress_seen(
6310 first,
6311 sup.dims.target_useful_shares(),
6312 sup.dims.target_useful_shares(),
6313 sup.dims.target_useful_shares(),
6314 sup.dims.polys_per_ssa(),
6315 )),
6316 t0,
6317 0,
6318 );
6319 sup.handle_event(&SessionPixEvent::Recovered(first), t0, 0);
6320
6321 assert_eq!(1, census(&sup).paid_tail, "the receipt must be visible");
6322 assert_eq!(
6323 PixGateMode::Funded,
6324 census(&sup).gate_mode,
6325 "a paid tail still authorizes service"
6326 );
6327
6328 sup.handle_event(
6330 &SessionPixEvent::RecoveryProgress(make_recovered_tail_progress(&sup, first, max_cycle_shares(&sup))),
6331 t0,
6332 0,
6333 );
6334 assert_eq!(0, census(&sup).paid_tail, "an exhausted receipt must leave the census");
6335 }
6336
6337 #[test]
6342 fn every_terminal_path_drains_the_census_and_counts_its_loss_once() {
6343 let t0 = Instant::now();
6344
6345 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
6354 enum Route {
6355 Retired,
6356 LiveAtClose,
6357 }
6358 type Kill = fn(&mut SessionPixSupervisor, HoprPseudonym, Instant);
6359 let cases: [(&str, Route, Kill); 5] = [
6360 ("commitment timeout", Route::Retired, |sup, p, t0| {
6361 sup.handle_event(&SessionPixEvent::SsaRequestSent(ssa_id(p, 1)), t0, 0);
6366 sup.handle_timers(t0 + Duration::from_secs(3600), 0);
6367 }),
6368 ("deposit observer closed", Route::Retired, |sup, p, t0| {
6369 let id = ssa_id(p, 1);
6370 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), t0, 0);
6371 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), t0, 0);
6372 sup.handle_event(&SessionPixEvent::DepositObserverClosed(id), t0, 0);
6373 }),
6374 ("deposit timeout", Route::Retired, |sup, p, t0| {
6375 let id = ssa_id(p, 1);
6376 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), t0, 0);
6377 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), t0, 0);
6378 sup.handle_timers(t0 + Duration::from_secs(7200), 0);
6379 }),
6380 ("recovery hard deadline", Route::Retired, |sup, p, t0| {
6381 fund_batch(sup, p, 1, t0);
6382 sup.handle_timers(t0 + Duration::from_secs(100_000), 0);
6383 }),
6384 ("unverifiable shares", Route::LiveAtClose, |sup, p, t0| {
6385 fund_batch(sup, p, 1, t0);
6386 sup.handle_event(
6387 &SessionPixEvent::UnverifiableShares {
6388 ssa_id: ssa_id(p, 1),
6389 observed_total: 1,
6390 peer: crate::supervision::test_peer(),
6391 },
6392 t0,
6393 0,
6394 );
6395 }),
6396 ];
6397
6398 for (name, route, kill) in cases {
6399 let p = pseudonym();
6400 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(64, 8), p, t0);
6401 let requested = sup.take_telemetry_events().requested;
6402 assert_eq!(1, requested, "{name}: one cycle was allocated");
6403
6404 kill(&mut sup, p, t0);
6405
6406 let events = sup.take_telemetry_events();
6407 assert_eq!(0, events.recovered, "{name}: nothing recovered");
6408
6409 let charged_here = events.failed;
6410 let still_live = census(&sup).live_cycles();
6411
6412 let (expected_charged, expected_live) = match route {
6415 Route::Retired => (1, 0),
6416 Route::LiveAtClose => (0, 1),
6417 };
6418 assert_eq!(
6419 (expected_charged, expected_live),
6420 (charged_here, still_live),
6421 "{name}: expected the {route:?} route"
6422 );
6423
6424 assert_eq!(
6429 requested,
6430 charged_here + still_live,
6431 "{name}: every requested cycle must be accounted for exactly once, got {charged_here} retired and \
6432 {still_live} live"
6433 );
6434 }
6435 }
6436
6437 #[test]
6443 fn retained_but_finished_cycles_are_not_live() {
6444 let t0 = Instant::now();
6445 let p = pseudonym();
6446 let cfg = SupervisorConfig {
6447 ssas_per_request: 2,
6448 ..default_cfg()
6449 };
6450 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(64, 8), p, t0);
6451
6452 fund_batch(&mut sup, p, 2, t0);
6453 assert_eq!(2, census(&sup).recovering);
6454
6455 sup.handle_event(&SessionPixEvent::Recovered(ssa_id(p, 1)), t0, 0);
6456 assert!(
6457 sup.ssas.iter().any(|s| matches!(s.phase, SsaPhase::Recovered { .. })),
6458 "the tombstone must still be retained, or this test proves nothing"
6459 );
6460 assert_eq!(
6461 1,
6462 census(&sup).recovering,
6463 "only the surviving sibling is live; the tombstone is retained state"
6464 );
6465 }
6466
6467 #[test]
6472 fn replayed_lifecycle_events_do_not_double_count() {
6473 let t0 = Instant::now();
6474 let p = pseudonym();
6475 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(64, 8), p, t0);
6476 let first = ssa_id(p, 1);
6477 sup.take_telemetry_events();
6478
6479 for _ in 0..3 {
6480 sup.handle_event(&SessionPixEvent::SsaRequestSent(first), t0, 0);
6481 sup.handle_event(&SessionPixEvent::CommitmentVerified(first), t0, 0);
6482 }
6483 assert_eq!(
6484 1,
6485 sup.take_telemetry_events().committed,
6486 "a repeated CommitmentVerified must count one transition"
6487 );
6488
6489 for _ in 0..3 {
6490 sup.handle_event(
6491 &SessionPixEvent::DepositConfirmed {
6492 ssa_id: first,
6493 amount: sufficient_balance(),
6494 },
6495 t0,
6496 0,
6497 );
6498 }
6499 assert_eq!(
6500 1,
6501 sup.take_telemetry_events().funded,
6502 "a duplicate deposit confirmation must count one transition"
6503 );
6504
6505 for _ in 0..3 {
6506 sup.handle_event(&SessionPixEvent::Recovered(first), t0, 0);
6507 }
6508 assert_eq!(
6509 1,
6510 sup.take_telemetry_events().recovered,
6511 "a duplicate Recovered must count one transition"
6512 );
6513 }
6514
6515 #[test]
6517 fn a_zero_deposit_counts_no_funding() {
6518 let t0 = Instant::now();
6519 let p = pseudonym();
6520 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(64, 8), p, t0);
6521 let first = ssa_id(p, 1);
6522
6523 sup.handle_event(&SessionPixEvent::SsaRequestSent(first), t0, 0);
6524 sup.handle_event(&SessionPixEvent::CommitmentVerified(first), t0, 0);
6525 sup.take_telemetry_events();
6526
6527 sup.handle_event(
6528 &SessionPixEvent::DepositConfirmed {
6529 ssa_id: first,
6530 amount: HoprBalance::zero(),
6531 },
6532 t0,
6533 0,
6534 );
6535
6536 assert_eq!(0, sup.take_telemetry_events().funded);
6537 assert_eq!(
6538 phases(PixGateMode::Predeposit, 0, 1, 0),
6539 census(&sup),
6540 "the cycle stays fundable, awaiting a real deposit"
6541 );
6542 }
6543
6544 fn recovering_session(p: HoprPseudonym, t0: Instant) -> SessionPixSupervisor {
6550 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(64, 8), p, t0);
6551 fund_batch(&mut sup, p, 1, t0);
6552 sup.take_telemetry_events();
6553 sup
6554 }
6555
6556 fn feed(sup: &mut SessionPixSupervisor, id: SsaId<HoprPseudonym>, useful: u64, seen: u64) -> (u64, u64) {
6558 let target = sup.dims.target_useful_shares();
6559 let polys = sup.dims.polys_per_ssa();
6560 let recovered = ((useful * polys as u64) / target.max(1)) as u16;
6563 sup.handle_event(
6564 &SessionPixEvent::RecoveryProgress(make_progress_seen(id, useful, seen, target, recovered)),
6565 Instant::now(),
6566 0,
6567 );
6568 let events = sup.take_telemetry_events();
6569 (events.useful_shares, events.surplus_shares)
6570 }
6571
6572 #[test]
6578 fn absolute_snapshots_become_positive_deltas() {
6579 let t0 = Instant::now();
6580 let p = pseudonym();
6581 let mut sup = recovering_session(p, t0);
6582 let id = ssa_id(p, 1);
6583
6584 assert_eq!((10, 0), feed(&mut sup, id, 10, 10), "the first snapshot is all delta");
6585 assert_eq!((5, 0), feed(&mut sup, id, 15, 15), "and the next is the difference");
6586
6587 assert_eq!((0, 0), feed(&mut sup, id, 15, 15), "a repeated snapshot adds nothing");
6589 assert_eq!((0, 0), feed(&mut sup, id, 12, 12), "a stale snapshot adds nothing");
6591 assert_eq!(
6593 (0, 0),
6594 feed(&mut sup, id, 15, 14),
6595 "an older liveness count adds nothing"
6596 );
6597
6598 assert_eq!(
6601 (85, 0),
6602 feed(&mut sup, id, 100, 100),
6603 "a jump carries the missing delta"
6604 );
6605 }
6606
6607 #[test]
6613 fn shares_that_advance_nothing_are_counted_as_surplus() {
6614 let t0 = Instant::now();
6615 let p = pseudonym();
6616 let mut sup = recovering_session(p, t0);
6617 let id = ssa_id(p, 1);
6618
6619 assert_eq!((20, 0), feed(&mut sup, id, 20, 20));
6620 assert_eq!(
6622 (0, 5),
6623 feed(&mut sup, id, 20, 25),
6624 "a surplus-only snapshot books surplus"
6625 );
6626 assert_eq!((10, 5), feed(&mut sup, id, 30, 40), "a mixed snapshot splits both ways");
6628 }
6629
6630 #[test]
6636 fn useful_and_surplus_always_partition_the_accepted_delta() {
6637 let t0 = Instant::now();
6638 let p = pseudonym();
6639 let mut sup = recovering_session(p, t0);
6640 let id = ssa_id(p, 1);
6641 let target = sup.dims.target_useful_shares();
6642 let max_seen = max_cycle_shares(&sup);
6646
6647 let mut booked_useful = 0;
6648 let mut booked_surplus = 0;
6649 let mut highest_seen = 0;
6650
6651 for step in [
6653 (5u64, 5u64),
6654 (5, 9),
6655 (3, 4),
6656 (9, 9),
6657 (9, 20),
6658 (9, 20),
6659 (30, 41),
6660 (12, 12),
6661 (target, max_seen),
6662 (target, max_seen),
6663 ] {
6664 let (useful, seen) = step;
6665 let (u, s) = feed(&mut sup, id, useful, seen);
6666 assert_eq!(
6667 seen.saturating_sub(highest_seen),
6668 u + s,
6669 "snapshot {step:?} must book exactly its accepted delta"
6670 );
6671 highest_seen = highest_seen.max(seen);
6672 booked_useful += u;
6673 booked_surplus += s;
6674 }
6675
6676 assert_eq!(
6677 highest_seen,
6678 booked_useful + booked_surplus,
6679 "over the whole sequence the two must sum to the greatest liveness count seen"
6680 );
6681 assert_eq!(
6682 target, booked_useful,
6683 "and the useful half must sum to the greatest useful count seen"
6684 );
6685 }
6686
6687 #[test]
6692 fn a_recovered_tail_books_only_surplus() {
6693 let t0 = Instant::now();
6694 let p = pseudonym();
6695 let mut sup = recovering_session(p, t0);
6696 let id = ssa_id(p, 1);
6697 let target = sup.dims.target_useful_shares();
6698
6699 feed(&mut sup, id, target, target);
6700 sup.handle_event(&SessionPixEvent::Recovered(id), t0, 0);
6701 sup.take_telemetry_events();
6702 assert!(sup.paid_recovery_tail.is_some(), "the fixture must leave a receipt");
6703
6704 sup.handle_event(
6705 &SessionPixEvent::RecoveryProgress(make_recovered_tail_progress(&sup, id, target + 40)),
6706 t0,
6707 0,
6708 );
6709 let events = sup.take_telemetry_events();
6710 assert_eq!(0, events.useful_shares, "a recovered cycle can advance nothing further");
6711 assert_eq!(40, events.surplus_shares);
6712 }
6713
6714 #[test]
6720 fn a_failed_cycle_is_summarized_with_its_partial_coverage() {
6721 let t0 = Instant::now();
6722 let p = pseudonym();
6723 let mut sup = recovering_session(p, t0);
6724 let id = ssa_id(p, 1);
6725 let target = sup.dims.target_useful_shares();
6726
6727 feed(&mut sup, id, target / 4, target / 2);
6730
6731 let actions = sup.handle_timers(t0 + Duration::from_secs(100_000), 5_000);
6733 assert!(
6734 actions
6735 .iter()
6736 .any(|action| matches!(action, SessionPixAction::Close(_))),
6737 "the fixture must actually retire the cycle, got {actions:?}"
6738 );
6739
6740 let events = sup.take_telemetry_events();
6741 assert_eq!(1, events.failed);
6742 assert_eq!(1, events.finalized.len(), "summarized exactly once");
6743 let summary = events.finalized[0];
6744 assert_eq!(PixCycleOutcome::Failed, summary.outcome);
6745 assert_eq!(target / 4, summary.useful_shares);
6746 assert_eq!(target / 2, summary.accepted_shares);
6747 assert_eq!(Some(0.25), summary.useful_fraction());
6748 assert_eq!(Some(0.5), summary.accepted_fraction());
6749 assert_eq!(
6750 5_000, summary.egress_packets,
6751 "egress is the Session's served count measured from when this cycle reached the front"
6752 );
6753 }
6754
6755 #[test]
6762 fn cycle_egress_is_measured_from_the_front_not_the_session_start() {
6763 let t0 = Instant::now();
6764 let p = pseudonym();
6765 let (mut sup, _) = SessionPixSupervisor::new(default_cfg(), dims(64, 8), p, t0);
6766 let id = ssa_id(p, 1);
6767
6768 sup.handle_event(&SessionPixEvent::SsaRequestSent(id), t0, 1_000);
6770 sup.handle_event(&SessionPixEvent::CommitmentVerified(id), t0, 1_000);
6771 sup.handle_event(
6772 &SessionPixEvent::DepositConfirmed {
6773 ssa_id: id,
6774 amount: sufficient_balance(),
6775 },
6776 t0,
6777 1_000,
6778 );
6779 sup.take_telemetry_events();
6780
6781 sup.handle_timers(t0 + Duration::from_secs(100_000), 5_000);
6782
6783 let events = sup.take_telemetry_events();
6784 assert_eq!(1, events.finalized.len());
6785 assert_eq!(
6786 4_000, events.finalized[0].egress_packets,
6787 "the 1 000 packets served before this cycle reached the front are not its cost"
6788 );
6789 }
6790
6791 #[test]
6797 fn a_queued_cycle_is_charged_no_egress() {
6798 let t0 = Instant::now();
6799 let p = pseudonym();
6800 let cfg = SupervisorConfig {
6801 ssas_per_request: 2,
6802 max_failed_cycles: 4,
6803 ..default_cfg()
6804 };
6805 let (mut sup, _) = SessionPixSupervisor::new(cfg, dims(64, 8), p, t0);
6806
6807 commit_unfunded(&mut sup, p, 1..=2, t0);
6809 sup.take_telemetry_events();
6810 sup.handle_event(&SessionPixEvent::DepositObserverClosed(ssa_id(p, 2)), t0, 9_999);
6811
6812 let events = sup.take_telemetry_events();
6813 assert_eq!(1, events.finalized.len());
6814 assert_eq!(
6815 0, events.finalized[0].egress_packets,
6816 "a cycle that never reached the front consumed none of the Session's egress"
6817 );
6818 }
6819
6820 #[test]
6822 fn a_cycle_with_no_target_has_no_coverage_fraction() {
6823 let summary = PixCycleSummary {
6824 outcome: PixCycleOutcome::Failed,
6825 egress_packets: 10,
6826 accepted_shares: 0,
6827 useful_shares: 0,
6828 target_useful_shares: 0,
6829 deposit_uhopr: 0,
6830 };
6831 assert_eq!(None, summary.useful_fraction());
6832 assert_eq!(None, summary.accepted_fraction());
6833 }
6834
6835 #[test]
6837 fn accepted_coverage_may_exceed_one() {
6838 let summary = PixCycleSummary {
6839 outcome: PixCycleOutcome::Recovered,
6840 egress_packets: 4096,
6841 accepted_shares: 768,
6842 useful_shares: 512,
6843 target_useful_shares: 512,
6844 deposit_uhopr: 0,
6845 };
6846 assert_eq!(Some(1.0), summary.useful_fraction());
6847 assert_eq!(Some(1.5), summary.accepted_fraction());
6848 }
6849
6850 #[test]
6852 fn predeposit_exposure_is_carried_through_from_the_gate() {
6853 let t0 = Instant::now();
6854 let p = pseudonym();
6855 let (sup, _) = SessionPixSupervisor::new(default_cfg(), dims(64, 8), p, t0);
6856
6857 assert_eq!(0, sup.telemetry_snapshot(0).predeposit_exposure_packets);
6858 assert_eq!(917, sup.telemetry_snapshot(917).predeposit_exposure_packets);
6859 }
6860}