1use std::{sync::Arc, time::Instant};
11
12use crossfire::{AsyncRx, MAsyncTx, SendError, TrySendError, mpsc::Array};
13use futures_time::future::FutureExt as TimeExt;
14use hopr_api::types::internal::prelude::HoprPseudonym;
15use hopr_protocol_pix::SsaRecoveryProgress;
16use hopr_utils::runtime::prelude::spawn;
17
18use super::{
19 PixParams, SessionPixAction, SessionPixCloseReason, SessionPixEvent, SupervisorConfig, gate::ServiceGate,
20 supervisor::SessionPixSupervisor, telemetry::PixSessionTelemetry,
21};
22
23type CmdChannel = Array<WorkerCommand>;
28type CmdTx = MAsyncTx<CmdChannel>;
29type CmdRx = AsyncRx<CmdChannel>;
30
31type ActionChannel = Array<SessionPixAction>;
32pub type ActionTx = MAsyncTx<ActionChannel>;
33pub type ActionRx = AsyncRx<ActionChannel>;
34
35#[derive(Clone)]
41pub struct SessionPixSupervisorHandle {
42 cmd_tx: CmdTx,
43 pub(crate) gate: Arc<ServiceGate>,
44 pub(crate) telemetry: Arc<PixSessionTelemetry>,
50}
51
52impl SessionPixSupervisorHandle {
53 pub async fn send_event(&self, ev: SessionPixEvent) -> Result<(), ()> {
59 match self.cmd_tx.send(WorkerCommand::Event(ev)).await {
60 Ok(()) => Ok(()),
61 Err(SendError(_)) => {
62 tracing::warn!("PIX supervisor command channel closed");
63 Err(())
64 }
65 }
66 }
67
68 pub fn try_send_event(&self, ev: SessionPixEvent) -> bool {
85 self.cmd_tx
86 .try_send(WorkerCommand::Event(ev))
87 .inspect_err(|e| match e {
88 TrySendError::Full(_) => tracing::debug!("supervisor command channel full — dropping event"),
89 TrySendError::Disconnected(_) => tracing::warn!("PIX supervisor command channel closed"),
90 })
91 .is_ok()
92 }
93
94 pub async fn send_action_result(&self, action: SessionPixAction, ok: bool) -> Result<(), ()> {
97 match self.cmd_tx.send(WorkerCommand::ActionResult { action, ok }).await {
98 Ok(()) => Ok(()),
99 Err(SendError(_)) => {
100 tracing::warn!("PIX supervisor result channel closed");
101 Err(())
102 }
103 }
104 }
105
106 pub fn try_send_progress(&self, progress: SsaRecoveryProgress<HoprPseudonym>) -> bool {
126 self.cmd_tx
127 .try_send(WorkerCommand::Event(SessionPixEvent::RecoveryProgress(progress)))
128 .inspect_err(|e| match e {
129 TrySendError::Full(_) => tracing::debug!(
130 ssa_id = %progress.ssa_id,
131 "supervisor command channel full — dropping progress snapshot"
132 ),
133 TrySendError::Disconnected(_) => tracing::warn!("PIX supervisor command channel closed"),
134 })
135 .is_ok()
136 }
137}
138
139pub enum WorkerCommand {
144 Event(SessionPixEvent),
145 ActionResult { action: SessionPixAction, ok: bool },
146}
147
148pub fn spawn_supervisor_worker(
154 cfg: SupervisorConfig,
155 dims: PixParams,
156 pseudonym: hopr_api::types::internal::prelude::HoprPseudonym,
157 now: Instant,
158) -> (SessionPixSupervisorHandle, ActionRx) {
159 let (cmd_tx, cmd_rx) = crossfire::mpsc::bounded_async::<WorkerCommand>(64);
160 let (action_tx, action_rx) = crossfire::mpsc::bounded_async::<SessionPixAction>(64);
161
162 let predeposit_budget = std::cmp::min(
163 dims.target_useful_shares().saturating_sub(1),
164 cfg.max_predeposit_packets,
165 );
166 let gate = ServiceGate::new(predeposit_budget, cfg.max_served_without_progress);
167 let telemetry = Arc::new(PixSessionTelemetry::new(gate.clone()));
168
169 let handle = SessionPixSupervisorHandle {
170 cmd_tx,
171 gate: gate.clone(),
172 telemetry: telemetry.clone(),
173 };
174
175 let (mut supervisor, initial_actions) = SessionPixSupervisor::new(cfg, dims, pseudonym, now);
176
177 publish_telemetry(&mut supervisor, &gate, &telemetry);
187
188 spawn(worker_loop(
189 supervisor,
190 cmd_rx,
191 action_tx,
192 gate,
193 telemetry,
194 initial_actions,
195 ));
196
197 (handle, action_rx)
198}
199
200async fn worker_loop(
205 mut supervisor: SessionPixSupervisor,
206 cmd_rx: CmdRx,
207 action_tx: ActionTx,
208 gate: Arc<ServiceGate>,
209 telemetry: Arc<PixSessionTelemetry>,
210 initial_actions: Vec<SessionPixAction>,
211) {
212 let keep_running = dispatch(&initial_actions, false, &action_tx, &gate);
217 publish_telemetry(&mut supervisor, &gate, &telemetry);
218 if !keep_running {
219 return;
220 }
221
222 loop {
223 let deadline = match (supervisor.next_deadline(), supervisor.next_fill_tick()) {
229 (Some(dl), Some(tick)) => Some(dl.min(tick)),
230 (dl, tick) => dl.or(tick),
231 };
232
233 if let Some(dl) = deadline {
234 let now = Instant::now();
235 if now >= dl {
236 let actions = supervisor.handle_timers(now, gate.served_total());
237 report_fill_stall(&mut supervisor);
238 let keep_running = dispatch(&actions, supervisor.closed, &action_tx, &gate);
239 publish_telemetry(&mut supervisor, &gate, &telemetry);
240 if !keep_running {
241 return;
242 }
243 continue;
244 }
245
246 let duration = dl.saturating_duration_since(Instant::now());
247
248 match cmd_rx
249 .recv()
250 .timeout(futures_time::time::Duration::from(duration))
251 .await
252 {
253 Ok(result) => {
254 if !process_cmd(result.ok(), &mut supervisor, &action_tx, &gate, &telemetry).await {
255 return;
256 }
257 }
258 Err(_) => {
259 let now = Instant::now();
260 let actions = supervisor.handle_timers(now, gate.served_total());
261 report_fill_stall(&mut supervisor);
262 let keep_running = dispatch(&actions, supervisor.closed, &action_tx, &gate);
263 publish_telemetry(&mut supervisor, &gate, &telemetry);
264 if !keep_running {
265 return;
266 }
267 }
268 }
269 } else {
270 let cmd = cmd_rx.recv().await.ok();
271 if !process_cmd(cmd, &mut supervisor, &action_tx, &gate, &telemetry).await {
272 return;
273 }
274 }
275 }
276}
277
278fn report_fill_stall(supervisor: &mut SessionPixSupervisor) {
285 if supervisor.take_fill_stall() {
286 #[cfg(feature = "telemetry")]
287 crate::telemetry::pix::record_pix_fill_backoff(crate::telemetry::pix::PixFillBackoff::Stalled);
288 }
289}
290
291fn publish_telemetry(
308 supervisor: &mut SessionPixSupervisor,
309 gate: &Arc<ServiceGate>,
310 telemetry: &Arc<PixSessionTelemetry>,
311) {
312 let snapshot = supervisor.telemetry_snapshot(gate.predeposit_exposure());
313 telemetry.publish(snapshot, supervisor.take_telemetry_events());
314}
315
316async fn process_cmd(
320 cmd: Option<WorkerCommand>,
321 supervisor: &mut SessionPixSupervisor,
322 action_tx: &ActionTx,
323 gate: &Arc<ServiceGate>,
324 telemetry: &Arc<PixSessionTelemetry>,
325) -> bool {
326 let cmd = match cmd {
327 Some(c) => c,
328 None => {
329 let actions = vec![SessionPixAction::Close(SessionPixCloseReason::SupervisorUnavailable)];
336 return dispatch(&actions, true, action_tx, gate);
337 }
338 };
339
340 let keep_running = match cmd {
341 WorkerCommand::Event(ev) => {
342 let now = Instant::now();
343 let actions = supervisor.handle_event(&ev, now, gate.served_total());
344 dispatch(&actions, supervisor.closed, action_tx, gate)
345 }
346 WorkerCommand::ActionResult { action, ok } => {
347 let now = Instant::now();
348 let actions = supervisor.action_result(&action, ok, now);
349 dispatch(&actions, supervisor.closed, action_tx, gate)
350 }
351 };
352
353 publish_telemetry(supervisor, gate, telemetry);
354 keep_running
355}
356
357fn dispatch(actions: &[SessionPixAction], closed: bool, action_tx: &ActionTx, gate: &Arc<ServiceGate>) -> bool {
376 for action in actions
381 .iter()
382 .take_while(|action| !matches!(action, SessionPixAction::Close(_)))
383 {
384 match action {
385 SessionPixAction::ReleaseService => gate.release_service(),
386 SessionPixAction::WithholdService => gate.withhold_service(),
387 SessionPixAction::ProgressNotification => gate.notify_progress(),
388 _ => {}
389 }
390 }
391
392 let terminal = closed || actions.iter().any(|a| matches!(a, SessionPixAction::Close(_)));
393
394 let delivered = send_actions(actions, action_tx);
397 if terminal || !delivered {
398 gate.poison();
399 return false;
400 }
401 true
402}
403
404fn send_actions(actions: &[SessionPixAction], action_tx: &ActionTx) -> bool {
415 for action in actions {
416 match action_tx.try_send(action.clone()) {
417 Ok(()) => continue,
418 Err(TrySendError::Full(item)) => {
419 if is_coalescible(&item) {
420 tracing::trace!(?action, "action channel full, dropping coalescible action");
421 continue;
422 }
423 tracing::warn!(?action, "non-coalescible action dropped — channel full");
424 return false;
425 }
426 Err(TrySendError::Disconnected(_item)) => {
427 tracing::warn!(?action, "action driver disconnected");
428 return false;
429 }
430 }
431 }
432 true
433}
434
435fn is_coalescible(action: &SessionPixAction) -> bool {
438 matches!(action, SessionPixAction::ProgressNotification)
439}
440
441#[cfg(test)]
446mod tests {
447 use std::time::{Duration, Instant};
448
449 use hopr_api::{
450 HoprBalance,
451 types::{crypto_random::Randomizable, internal::prelude::HoprPseudonym},
452 };
453 use hopr_protocol_pix::{SsaId, SsaIndex, SsaRecoveryProgress};
454
455 use super::{
456 super::{
457 FillRate, PixFillConfig, SAMPLING_INTERVAL,
458 gate::{GateBlockReason, GateVerdict},
459 },
460 *,
461 };
462
463 #[tokio::test]
471 async fn the_initial_census_survives_a_release_before_the_worker_runs() {
472 use crate::supervision::telemetry::probe;
473
474 probe::reset();
475 let p = HoprPseudonym::random();
476 let (handle, _action_rx) = spawn_supervisor_worker(default_cfg(), dims(), p, Instant::now());
477
478 assert_eq!(
480 1,
481 probe::get("cycles_total/requested"),
482 "the first batch's allocation is published before the worker is scheduled"
483 );
484 assert_eq!(1, probe::get("cycles_active/awaiting_commitment"));
485 assert_eq!(1, probe::get("sessions_active/predeposit"));
486
487 handle.telemetry.release();
489 assert_eq!(
490 0,
491 probe::get("cycles_active/awaiting_commitment"),
492 "release must return the census it found"
493 );
494 assert_eq!(0, probe::get("sessions_active/predeposit"));
495 assert_eq!(
496 1,
497 probe::get("cycles_total/failed"),
498 "and charge the cycle that never got anywhere, so requested still balances"
499 );
500 assert_eq!(
501 probe::get("cycles_total/requested"),
502 probe::get("cycles_total/recovered") + probe::get("cycles_total/failed"),
503 );
504 }
505
506 fn default_cfg() -> SupervisorConfig {
507 SupervisorConfig {
508 ssas_per_request: 1,
509 allow_dynamic_ssa_batches: true,
510 max_failed_cycles: 1,
511 max_ssa_delivery_time: Duration::from_secs(20),
512 commitment_recommit_interval: Duration::from_secs(3),
513 max_deposit_wait: Duration::from_secs(60),
514 max_recovery_idle: Duration::from_secs(10),
515 max_recovery_time: Duration::from_secs(3600),
516 max_off_front_share_fraction: 0.25,
517 min_share_order_sample: 16384,
518 max_predeposit_packets: 1024,
519 max_served_without_progress: 256,
520 tombstone_retention_window: Duration::from_secs(30),
521 fill: PixFillConfig::default(),
522 }
523 }
524
525 fn dims() -> PixParams {
527 PixParams::try_new(10, 5, 7, crate::types::LOCAL_PIX_SUITE).expect("test dimensions must be valid")
528 }
529
530 async fn poll_until(what: &str, mut condition: impl FnMut() -> bool) {
538 const DEADLINE: Duration = Duration::from_secs(5);
539 tokio::time::timeout(DEADLINE, async {
540 while !condition() {
541 tokio::time::sleep(Duration::from_millis(5)).await;
542 }
543 })
544 .await
545 .unwrap_or_else(|_| panic!("{what} — not observed within {DEADLINE:?}"));
546 }
547
548 #[tokio::test]
549 async fn worker_creates_and_forwards_initial_request() {
550 let (_handle, action_rx) =
551 spawn_supervisor_worker(default_cfg(), dims(), HoprPseudonym::random(), Instant::now());
552
553 let action = tokio::time::timeout(Duration::from_secs(1), action_rx.recv())
554 .await
555 .expect("timeout waiting for initial action")
556 .expect("action stream ended");
557
558 match action {
559 SessionPixAction::RequestSsa { ssa_ids, .. } => {
560 assert_eq!(ssa_ids[0].ssa_index(), SsaIndex::new(1).unwrap());
561 }
562 other => panic!("expected RequestSsa, got {other:?}"),
563 }
564 }
565
566 #[tokio::test]
573 async fn zero_predeposit_config_reaches_the_gate_as_strict_prepay() {
574 let cfg = SupervisorConfig {
575 max_predeposit_packets: 0,
576 max_served_without_progress: 1,
579 ..default_cfg()
580 };
581 let (handle, _action_rx) = spawn_supervisor_worker(cfg, dims(), HoprPseudonym::random(), Instant::now());
582
583 assert_ne!(
584 GateVerdict::Admitted,
585 handle.gate.try_acquire_sync().expect("a fresh gate is not poisoned"),
586 "a strict-prepay gate must refuse the first packet"
587 );
588 assert_eq!(handle.gate.served_total(), 0);
589
590 handle.gate.release_service();
592 assert_eq!(
593 GateVerdict::Admitted,
594 handle.gate.try_acquire_sync().expect("a funded gate is not poisoned"),
595 "funding must open a strict-prepay gate"
596 );
597 assert_eq!(handle.gate.served_total(), 1);
598 }
599
600 #[tokio::test]
606 async fn predeposit_budget_is_bounded_by_the_ssa_dimensions() {
607 let cfg = SupervisorConfig {
608 max_predeposit_packets: 10_000,
609 ..default_cfg()
610 };
611 let (handle, _action_rx) = spawn_supervisor_worker(cfg, dims(), HoprPseudonym::random(), Instant::now());
612
613 for i in 0..49 {
614 assert_eq!(
615 GateVerdict::Admitted,
616 handle.gate.try_acquire_sync().expect("a fresh gate is not poisoned"),
617 "packet {i} is within `target_useful_shares - 1` and must be served"
618 );
619 }
620 assert_ne!(
621 GateVerdict::Admitted,
622 handle.gate.try_acquire_sync().expect("a fresh gate is not poisoned"),
623 "the budget must stop at `target_useful_shares - 1`, not at the configured cap"
624 );
625 }
626
627 #[tokio::test]
628 async fn dropped_action_driver_fails_closed_and_poisons_gate() {
629 let p = HoprPseudonym::random();
630 let (handle, action_rx) = spawn_supervisor_worker(default_cfg(), dims(), p, Instant::now());
631
632 drop(action_rx);
634
635 poll_until("the gate is poisoned after the action driver is dropped", || {
636 handle.gate.try_acquire_sync().is_err()
637 })
638 .await;
639
640 assert!(
641 handle.gate.acquire().await.is_err(),
642 "a poisoned gate must refuse the awaiting path too, not only the synchronous one"
643 );
644 }
645
646 #[tokio::test]
647 async fn worker_rearms_strict_prepay_without_waiting_for_the_action_driver() -> anyhow::Result<()> {
648 let cfg = SupervisorConfig {
649 max_predeposit_packets: 0,
650 ..default_cfg()
651 };
652 let p = HoprPseudonym::random();
653 let (handle, _stalled_action_rx) = spawn_supervisor_worker(cfg, dims(), p, Instant::now());
654 let first = SsaId::new(
655 p,
656 SsaIndex::new(1).ok_or_else(|| anyhow::anyhow!("SSA index 1 must be valid"))?,
657 );
658
659 handle
660 .send_event(SessionPixEvent::SsaRequestSent(first))
661 .await
662 .map_err(|()| anyhow::anyhow!("worker stopped"))?;
663 handle
664 .send_event(SessionPixEvent::CommitmentVerified(first))
665 .await
666 .map_err(|()| anyhow::anyhow!("worker stopped"))?;
667 handle
668 .send_event(SessionPixEvent::DepositConfirmed {
669 ssa_id: first,
670 amount: HoprBalance::new_base(1),
671 })
672 .await
673 .map_err(|()| anyhow::anyhow!("worker stopped"))?;
674
675 poll_until("the paid front opens the gate", || handle.gate.funded()).await;
676 assert_eq!(GateVerdict::Admitted, handle.gate.try_acquire_sync()?);
677
678 handle
682 .send_event(SessionPixEvent::Recovered(first))
683 .await
684 .map_err(|()| anyhow::anyhow!("worker stopped"))?;
685 poll_until("the recovered predecessor keeps its paid tail open", || {
686 handle.gate.funded()
687 })
688 .await;
689 assert_eq!(
690 GateVerdict::Admitted,
691 handle.gate.try_acquire_sync()?,
692 "cryptographic recovery must reopen a gate whose final progress notification was lost"
693 );
694
695 let successor = SsaId::new(
699 p,
700 SsaIndex::new(2).ok_or_else(|| anyhow::anyhow!("SSA index 2 must be valid"))?,
701 );
702 handle
703 .send_event(SessionPixEvent::RecoveryProgress(SsaRecoveryProgress {
704 ssa_id: successor,
705 useful_shares: 1,
706 shares_seen: 1,
707 target_useful_shares: dims().target_useful_shares(),
708 recovered_polynomials: 0,
709 }))
710 .await
711 .map_err(|()| anyhow::anyhow!("worker stopped"))?;
712 poll_until("the unfunded successor closes the gate", || !handle.gate.funded()).await;
713 assert_eq!(
714 GateVerdict::Blocked(GateBlockReason::PredepositExhausted),
715 handle.gate.try_acquire_sync()?,
716 "an unfunded successor refuses on its allowance, not on the funded ceiling"
717 );
718 Ok(())
719 }
720
721 #[tokio::test]
722 async fn event_sent_via_handle_reaches_core() {
723 let p = HoprPseudonym::random();
724 let (handle, action_rx) = spawn_supervisor_worker(default_cfg(), dims(), p, Instant::now());
725
726 let _initial = tokio::time::timeout(Duration::from_secs(1), action_rx.recv())
728 .await
729 .expect("timeout")
730 .expect("action stream ended");
731
732 let id = SsaId::new(p, SsaIndex::new(1).unwrap());
735 handle.send_event(SessionPixEvent::SsaRequestSent(id)).await.unwrap();
736
737 tokio::time::sleep(Duration::from_millis(50)).await;
739
740 let maybe_action = tokio::time::timeout(Duration::from_millis(50), action_rx.recv()).await;
742 assert!(maybe_action.is_err(), "expected no extra actions");
743 }
744
745 #[tokio::test]
746 async fn action_result_feedback_processed() {
747 let p = HoprPseudonym::random();
748 let (handle, action_rx) = spawn_supervisor_worker(default_cfg(), dims(), p, Instant::now());
749
750 let _initial = tokio::time::timeout(Duration::from_secs(1), action_rx.recv())
752 .await
753 .expect("timeout")
754 .expect("action stream ended");
755
756 handle
758 .send_action_result(
759 SessionPixAction::RequestSsa {
760 ssa_ids: vec![SsaId::new(p, SsaIndex::new(1).unwrap())],
761 params: dims(),
762 },
763 false,
764 )
765 .await
766 .unwrap();
767
768 let close_action = tokio::time::timeout(Duration::from_secs(1), action_rx.recv())
769 .await
770 .expect("timeout")
771 .expect("action stream ended");
772 assert!(matches!(close_action, SessionPixAction::Close(_)));
773 }
774
775 #[tokio::test]
776 async fn deadline_via_worker_closes() {
777 let mut cfg = default_cfg();
778 cfg.max_ssa_delivery_time = Duration::from_millis(10);
779 let p = HoprPseudonym::random();
780 let (handle, action_rx) = spawn_supervisor_worker(cfg, dims(), p, Instant::now());
781
782 let _initial = tokio::time::timeout(Duration::from_secs(1), action_rx.recv())
784 .await
785 .expect("timeout")
786 .expect("action stream ended");
787
788 let id = SsaId::new(p, SsaIndex::new(1).unwrap());
790 handle.send_event(SessionPixEvent::SsaRequestSent(id)).await.unwrap();
791
792 let close_action = tokio::time::timeout(Duration::from_secs(2), action_rx.recv())
794 .await
795 .expect("timeout")
796 .expect("action stream ended");
797 assert!(matches!(
798 close_action,
799 SessionPixAction::Close(SessionPixCloseReason::CommitmentTimeout)
800 ));
801 }
802
803 #[tokio::test]
808 async fn worker_terminates_when_all_command_senders_dropped() {
809 let (handle, _action_rx) =
810 spawn_supervisor_worker(default_cfg(), dims(), HoprPseudonym::random(), Instant::now());
811
812 let gate = handle.gate.clone();
815
816 drop(handle);
818
819 poll_until("the gate is poisoned after all command senders are dropped", || {
820 gate.try_acquire_sync().is_err()
821 })
822 .await;
823 }
824
825 #[tokio::test]
826 async fn worker_terminates_when_supervisor_closes_after_event() {
827 let mut cfg = default_cfg();
828 cfg.max_ssa_delivery_time = Duration::from_millis(10);
829 let p = HoprPseudonym::random();
830 let (handle, action_rx) = spawn_supervisor_worker(cfg, dims(), p, Instant::now());
831
832 let _initial = tokio::time::timeout(Duration::from_secs(1), action_rx.recv())
834 .await
835 .expect("timeout")
836 .expect("action stream ended");
837
838 let id = SsaId::new(p, SsaIndex::new(1).unwrap());
840 handle.send_event(SessionPixEvent::SsaRequestSent(id)).await.unwrap();
841
842 let close_action = tokio::time::timeout(Duration::from_secs(2), action_rx.recv())
845 .await
846 .expect("timeout waiting for close action")
847 .expect("action stream ended before close");
848 assert!(
849 matches!(
850 close_action,
851 SessionPixAction::Close(SessionPixCloseReason::CommitmentTimeout)
852 ),
853 "expected Close due to commitment timeout, got {close_action:?}"
854 );
855 }
856
857 #[tokio::test]
865 async fn a_close_the_supervisor_did_not_flag_still_poisons_the_gate() {
866 let p = HoprPseudonym::random();
867 let (handle, action_rx) = spawn_supervisor_worker(default_cfg(), dims(), p, Instant::now());
868
869 let _initial = tokio::time::timeout(Duration::from_secs(1), action_rx.recv())
871 .await
872 .expect("timeout")
873 .expect("action stream ended");
874
875 let foreign = SsaId::new(HoprPseudonym::random(), SsaIndex::new(1).unwrap());
876 handle
877 .send_event(SessionPixEvent::SsaRequestSent(foreign))
878 .await
879 .expect("worker must accept the event");
880
881 let close_action = tokio::time::timeout(Duration::from_secs(1), action_rx.recv())
882 .await
883 .expect("timeout waiting for close action")
884 .expect("action stream ended before close");
885 assert!(
886 matches!(
887 close_action,
888 SessionPixAction::Close(SessionPixCloseReason::InvalidTransition)
889 ),
890 "expected Close on a foreign pseudonym, got {close_action:?}"
891 );
892
893 poll_until("the gate is poisoned by a close the supervisor did not flag", || {
894 handle.gate.try_acquire_sync().is_err()
895 })
896 .await;
897 }
898
899 #[tokio::test]
900 async fn worker_terminates_on_action_send_failure() {
901 let p = HoprPseudonym::random();
902 let (handle, action_rx) = spawn_supervisor_worker(default_cfg(), dims(), p, Instant::now());
903
904 drop(action_rx);
906
907 let id = SsaId::new(p, SsaIndex::new(1).unwrap());
911 let terminated = tokio::time::timeout(Duration::from_secs(5), async {
912 while handle.send_event(SessionPixEvent::SsaRequestSent(id)).await.is_ok() {
913 tokio::time::sleep(Duration::from_millis(5)).await;
914 }
915 })
916 .await;
917
918 assert!(
919 terminated.is_ok(),
920 "the worker must exit after a failed action send, which closes the command channel"
921 );
922 }
923
924 #[tokio::test]
925 async fn send_event_on_disconnected_channel_returns_error() {
926 let (cmd_tx, cmd_rx) = crossfire::mpsc::bounded_async::<WorkerCommand>(2);
927 let gate = ServiceGate::new(1, 256);
928 let handle = SessionPixSupervisorHandle {
929 cmd_tx,
930 gate: gate.clone(),
931 telemetry: Arc::new(PixSessionTelemetry::new(gate.clone())),
932 };
933
934 drop(cmd_rx);
936
937 let id = SsaId::new(HoprPseudonym::random(), SsaIndex::new(1).unwrap());
938 assert!(handle.send_event(SessionPixEvent::SsaRequestSent(id)).await.is_err());
939 assert!(
940 handle
941 .send_action_result(
942 SessionPixAction::RequestSsa {
943 ssa_ids: vec![id],
944 params: dims(),
945 },
946 true,
947 )
948 .await
949 .is_err()
950 );
951 }
952
953 #[tokio::test]
958 async fn command_channel_backpressures_on_full() {
959 let (cmd_tx, _cmd_rx) = crossfire::mpsc::bounded_async::<WorkerCommand>(1);
961 let gate = ServiceGate::new(1, 256);
962 let handle = SessionPixSupervisorHandle {
963 cmd_tx,
964 gate: gate.clone(),
965 telemetry: Arc::new(PixSessionTelemetry::new(gate.clone())),
966 };
967
968 let id = SsaId::new(HoprPseudonym::random(), SsaIndex::new(1).unwrap());
969
970 handle.send_event(SessionPixEvent::SsaRequestSent(id)).await.unwrap();
972
973 let result = tokio::time::timeout(
975 Duration::from_millis(50),
976 handle.send_event(SessionPixEvent::SsaRequestSent(id)),
977 )
978 .await;
979
980 assert!(result.is_err(), "send_event should backpressure when channel is full");
983 }
984
985 #[tokio::test]
986 async fn backpressure_releases_when_channel_drained() {
987 let (cmd_tx, cmd_rx) = crossfire::mpsc::bounded_async::<WorkerCommand>(1);
988 let gate = ServiceGate::new(1, 256);
989 let handle = SessionPixSupervisorHandle {
990 cmd_tx,
991 gate: gate.clone(),
992 telemetry: Arc::new(PixSessionTelemetry::new(gate.clone())),
993 };
994
995 let id = SsaId::new(HoprPseudonym::random(), SsaIndex::new(1).unwrap());
996
997 handle.send_event(SessionPixEvent::SsaRequestSent(id)).await.unwrap();
999
1000 let parked = {
1002 let handle = handle.clone();
1003 tokio::spawn(async move { handle.send_event(SessionPixEvent::SsaRequestSent(id)).await })
1004 };
1005
1006 tokio::time::sleep(Duration::from_millis(20)).await;
1008
1009 let drained = cmd_rx.recv().await;
1011 assert!(drained.is_ok(), "expected one command in the channel");
1012
1013 let result = tokio::time::timeout(Duration::from_secs(1), parked).await;
1015 assert!(result.is_ok(), "parked send should complete after channel is drained");
1016 assert!(result.unwrap().is_ok(), "send should succeed");
1017 }
1018
1019 #[tokio::test]
1024 async fn coalescible_dropped_on_full_non_coalescible_and_disconnected_are_fatal() {
1025 let (action_tx, _action_rx) = crossfire::mpsc::bounded_async::<SessionPixAction>(1);
1026
1027 action_tx.try_send(SessionPixAction::ReleaseService).unwrap();
1029
1030 let progress: Vec<_> = (0..100).map(|_| SessionPixAction::ProgressNotification).collect();
1033 assert!(send_actions(&progress, &action_tx));
1034
1035 assert!(!send_actions(&[SessionPixAction::WithholdService], &action_tx));
1036
1037 assert!(!send_actions(
1040 &[SessionPixAction::Close(SessionPixCloseReason::CommitmentTimeout)],
1041 &action_tx
1042 ));
1043
1044 let (action_tx2, action_rx2) = crossfire::mpsc::bounded_async::<SessionPixAction>(1);
1047 drop(action_rx2);
1048 assert!(!send_actions(&progress, &action_tx2));
1049 }
1050
1051 async fn next_fill_rate(action_rx: &ActionRx, within: Duration) -> Option<FillRate> {
1061 tokio::time::timeout(within, async {
1062 loop {
1063 match action_rx.recv().await {
1064 Ok(SessionPixAction::SetFillRate(rate)) => break Some(rate),
1065 Ok(_) => continue,
1066 Err(_) => break None,
1067 }
1068 }
1069 })
1070 .await
1071 .ok()
1072 .flatten()
1073 }
1074
1075 async fn fund_first_cycle(handle: &SessionPixSupervisorHandle, p: HoprPseudonym) -> Result<(), ()> {
1077 let id = SsaId::new(p, SsaIndex::new(1).expect("index one is non-zero"));
1078 handle.send_event(SessionPixEvent::SsaRequestSent(id)).await?;
1079 handle.send_event(SessionPixEvent::CommitmentVerified(id)).await?;
1080 handle
1081 .send_event(SessionPixEvent::DepositConfirmed {
1082 ssa_id: id,
1083 amount: HoprBalance::new_base(1000),
1084 })
1085 .await
1086 }
1087
1088 #[tokio::test]
1095 async fn the_worker_emits_a_fill_rate_within_two_ticks_of_funding() -> anyhow::Result<()> {
1096 let p = HoprPseudonym::random();
1097 let (handle, action_rx) = spawn_supervisor_worker(default_cfg(), dims(), p, Instant::now());
1098
1099 let initial = next_fill_rate(&action_rx, 2 * SAMPLING_INTERVAL).await;
1103 assert_eq!(None, initial, "an unfunded Session must not be filled");
1104
1105 fund_first_cycle(&handle, p)
1106 .await
1107 .map_err(|()| anyhow::anyhow!("worker stopped"))?;
1108
1109 let rate = next_fill_rate(&action_rx, 2 * SAMPLING_INTERVAL + Duration::from_secs(1))
1110 .await
1111 .ok_or_else(|| anyhow::anyhow!("no fill rate was emitted within two sampling intervals of funding"))?;
1112 assert!(!rate.is_zero(), "a funded cycle must be filled, got {rate:?}");
1113 Ok(())
1114 }
1115
1116 #[tokio::test]
1122 async fn the_worker_delivers_a_zero_fill_rate_ahead_of_the_close() -> anyhow::Result<()> {
1123 let p = HoprPseudonym::random();
1124 let (handle, action_rx) = spawn_supervisor_worker(default_cfg(), dims(), p, Instant::now());
1125
1126 fund_first_cycle(&handle, p)
1127 .await
1128 .map_err(|()| anyhow::anyhow!("worker stopped"))?;
1129 let rate = next_fill_rate(&action_rx, 2 * SAMPLING_INTERVAL + Duration::from_secs(1))
1130 .await
1131 .ok_or_else(|| anyhow::anyhow!("fill must be running before the close is meaningful"))?;
1132 assert!(!rate.is_zero());
1133
1134 handle
1135 .send_event(SessionPixEvent::UnverifiableShares {
1136 ssa_id: SsaId::new(p, SsaIndex::new(1).expect("index one is non-zero")),
1137 observed_total: 1,
1138 peer: crate::supervision::test_peer(),
1139 })
1140 .await
1141 .map_err(|()| anyhow::anyhow!("worker stopped"))?;
1142
1143 let mut saw_zero = false;
1144 let ordering = tokio::time::timeout(Duration::from_secs(2), async {
1145 loop {
1146 match action_rx.recv().await {
1147 Ok(SessionPixAction::SetFillRate(rate)) => saw_zero |= rate.is_zero(),
1148 Ok(SessionPixAction::Close(_)) => break saw_zero,
1149 Ok(_) => continue,
1150 Err(_) => break saw_zero,
1151 }
1152 }
1153 })
1154 .await
1155 .map_err(|_| anyhow::anyhow!("no close was delivered"))?;
1156
1157 assert!(
1158 ordering,
1159 "the zero fill rate must precede the close on the action channel"
1160 );
1161 Ok(())
1162 }
1163}