Skip to main content

hopr_transport_session/supervision/
worker.rs

1//! Per-session actor for the [`SessionPixSupervisor`].
2//!
3//! The worker serializes lifecycle events through the deterministic core,
4//! manages the deadline timer, applies safety-critical gate actions locally,
5//! and forwards actions to an external I/O driver.
6//!
7//! Runtime-agnostic: uses crossfire channels and the runtime prelude from
8//! `hopr_utils` so no direct tokio dependency (tests use tokio freely).
9
10use 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
23// ---------------------------------------------------------------------------
24// Channel type aliases
25// ---------------------------------------------------------------------------
26
27type 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// ---------------------------------------------------------------------------
36// SessionPixSupervisorHandle
37// ---------------------------------------------------------------------------
38
39/// Cloneable handle to a running `SessionPixWorker`.
40#[derive(Clone)]
41pub struct SessionPixSupervisorHandle {
42    cmd_tx: CmdTx,
43    pub(crate) gate: Arc<ServiceGate>,
44    /// This Session's share of the node-level PIX aggregates.
45    ///
46    /// Carried here rather than on `SessionSlot` because the slot already holds this handle, and
47    /// both parties that need it can reach it: the worker publishes through its own clone, and
48    /// `close_session` releases through the slot's.
49    pub(crate) telemetry: Arc<PixSessionTelemetry>,
50}
51
52impl SessionPixSupervisorHandle {
53    /// Send a PIX event to the supervisor, awaiting capacity if the channel is
54    /// full.
55    ///
56    /// Returns `Err` if the worker is no longer running. Backpressures instead
57    /// of dropping events, so overflow cannot occur by construction.
58    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    /// Deliver a PIX event without blocking, dropping it if the channel is full.
69    ///
70    /// Returns `true` if the event was queued.
71    ///
72    /// The synchronous counterpart to [`send_event`](Self::send_event), and it exists because one
73    /// caller cannot await: `close_session_with_reason` is a plain `fn`, reached from the manager's
74    /// closure-notification task — which `HoprSession::poll_close` and the empty-read path feed
75    /// through a non-blocking channel — from the public synchronous `SessionManager::close_session`,
76    /// and from `handle_session_error` on a peer `SessionError`. None of them may park on a worker
77    /// that happens to be mid-tick.
78    ///
79    /// A refusal is not lost work, which is what makes dropping acceptable here where it would not be
80    /// for a lifecycle transition. The only event sent this way is
81    /// [`SessionPixEvent::SessionClosed`], and its caller treats `false` as "this close was not
82    /// answered by the supervisor" and falls back to the immediate teardown that has always been the
83    /// behaviour on that path.
84    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    /// Send an action result feedback to the supervisor, awaiting capacity if
95    /// the channel is full.
96    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    /// Deliver a recovery-progress snapshot without blocking, dropping it if the channel is full.
107    ///
108    /// Returns `true` if the snapshot was queued.
109    ///
110    /// Unlike every other event, progress arrives once per acknowledgement batch rather than once
111    /// per SSA lifecycle transition, so it is the one input whose rate is set by traffic. Awaiting
112    /// capacity for it would put the supervisor's scheduling latency on the acknowledgement path —
113    /// where a stall costs share verification throughput for the whole Session.
114    ///
115    /// Dropping is safe by construction rather than by tolerance: snapshots carry absolute counters,
116    /// [`on_recovery_progress`](super::supervisor::SessionPixSupervisor) keeps the maximum it has
117    /// seen, and a lower or repeated `useful_shares` is already treated as benign — concurrent
118    /// acknowledgement batches make out-of-order snapshots ordinary. A dropped snapshot is therefore
119    /// indistinguishable from one that arrived out of order, and the next batch supersedes it.
120    ///
121    /// The one thing a drop delays is the [`ProgressNotification`](SessionPixAction::ProgressNotification)
122    /// that resets the gate's served-without-progress ceiling, so `max_served_without_progress` has
123    /// to leave room for a few missed snapshots. Since the command channel is drained by a state
124    /// machine that performs no I/O, drops require a genuinely wedged worker.
125    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
139// ---------------------------------------------------------------------------
140// WorkerCommand
141// ---------------------------------------------------------------------------
142
143pub enum WorkerCommand {
144    Event(SessionPixEvent),
145    ActionResult { action: SessionPixAction, ok: bool },
146}
147
148// ---------------------------------------------------------------------------
149// spawn_supervisor_worker
150// ---------------------------------------------------------------------------
151
152/// Spawn a supervisor worker and return its handle and action driver receiver.
153pub 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    // Published here rather than left to the worker's first turn, because the worker is spawned and
178    // this function returns before it is scheduled. A Session torn down inside that window —
179    // establishment failing after the supervisor is stood up is the realistic way — would have its
180    // telemetry released before anything was ever published, and the first batch's `requested`
181    // count would be lost along with the census it belonged to.
182    //
183    // Correct to do before `dispatch`: a fresh supervisor has no funded front, so the initial
184    // actions change neither the gate mode nor its exposure, and the worker's own publish after
185    // dispatch then finds nothing to move.
186    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
200// ---------------------------------------------------------------------------
201// Worker loop
202// ---------------------------------------------------------------------------
203
204async 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    // Emit initial actions. `false` says only that a freshly built supervisor has not flagged
213    // itself, which it cannot have: `new` does not close. It is not a claim that these actions are
214    // non-terminal — `dispatch` reads the payload for that, so a construction-time `Close` would
215    // still fail closed here rather than being waved through on the strength of the flag.
216    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        // Two independent timers, one wake-up. The deadline sweep is the lifecycle's, the fill tick
224        // is the rate planner's, and neither may starve the other — so the loop always sleeps until
225        // the earlier of the two and lets `handle_timers` decide which of them was actually due.
226        // `next_fill_tick` is `None` unless there is something to plan or something to stop, so a
227        // Session with no funded cycle waits on its command channel exactly as it did before.
228        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
278/// Counts a fill stall, if one just began.
279///
280/// The planner cannot do this itself — it is a pure function of its inputs, and a metric is neither —
281/// so the edge is latched there and drained here, in the actor that is allowed to have effects. Not
282/// folded into `dispatch`, because the stall is not an action: it produces no work for the driver,
283/// only a number for the operator.
284fn 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
291/// Publishes this Session's share of the node-level PIX aggregates.
292///
293/// Called once per turn, after [`dispatch`] rather than before it, and on the terminal path as well
294/// as the ordinary one. Both halves of that matter:
295///
296/// * *after*, because `dispatch` applies the gate transitions locally before sending anything, and the gate is where
297///   the predeposit exposure is read from. Publishing first would report a rotating Session's exhausted predecessor
298///   allowance for one more turn.
299/// * *on the terminal path too*, because that turn is the one that carries the last `failed`/`recovered` counts, and
300///   the worker is about to stop. Returning the gauges to zero is not this function's job —
301///   [`PixSessionTelemetry::release`] does that from `close_session`, which knows the Session is gone rather than
302///   merely that this worker has stopped.
303///
304/// Kept out of `dispatch` for the reason `report_fill_stall` is: this produces no work for the
305/// action driver, only numbers for the operator, and `dispatch`'s callers hold the supervisor by
306/// different borrows.
307fn 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
316/// Handle a received command from the handle.
317///
318/// Returns `false` to signal the worker loop to stop.
319async 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            // All senders dropped — close. Terminal by construction, so `dispatch` is told the
330            // supervisor is closed and its `false` is the verdict.
331            //
332            // Nothing is published: this close is synthesized here rather than by the state machine,
333            // so no transition was latched and the census is whatever the previous turn already
334            // reported. `release` charges its live cycles when the Session is torn down.
335            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
357/// Apply gate control, forward `actions`, then report whether the worker should keep running.
358///
359/// The four places that produce actions — the two deadline paths in [`worker_loop`] and the two
360/// command arms in [`process_cmd`] — all had the same four-way branch spelled out: send, check
361/// whether the supervisor closed, poison, return. Fail-close is the property that matters most here
362/// and it was stated four times, so a correction to it had to be made four times.
363///
364/// Poisons the gate on every terminal path, so no caller has to remember to. `closed` is passed
365/// rather than read from the supervisor because the callers hold it by different borrows.
366///
367/// Terminality is derived from the *actions* as well as from `closed`, because the two do not
368/// always agree: several handlers return [`Close`](SessionPixAction::Close) without setting the
369/// supervisor's flag — both `InvalidTransition` paths in `on_ssa_request_sent`, `CounterRegression`
370/// and the share-order verdict in `on_recovery_progress`, and `on_unverifiable_shares`. Two of those
371/// are the anti-abuse verdicts. Reading only the flag would send the close, return `true`, and leave
372/// the gate open for as long as it takes the action driver to poison it on receipt — which is a real
373/// but bounded window, not a correct one. `Close` is also the one action the driver never reports
374/// back, so `action_result`'s own close path cannot cover for it.
375fn dispatch(actions: &[SessionPixAction], closed: bool, action_tx: &ActionTx, gate: &Arc<ServiceGate>) -> bool {
376    // Gate control is local state, not I/O. Apply it before touching the action channel so a safety
377    // transition cannot wait behind commitment generation, deposit-data lookup, or a network send in
378    // the action driver. Stop at Close to preserve the driver's rule that actions after a close are
379    // unreachable; poisoning below is stronger than every gate mode.
380    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    // Sent before the verdict either way: a closing supervisor's last actions carry the reason it
395    // closed, and dropping them would leave the driver to infer it.
396    let delivered = send_actions(actions, action_tx);
397    if terminal || !delivered {
398        gate.poison();
399        return false;
400    }
401    true
402}
403
404/// Non-blocking forward of actions to the driver.
405///
406/// On `Disconnected`, the driver is gone — returns `false` so the caller
407/// can fail-close.
408///
409/// On `Full`:
410/// - **Coalescible** actions (`ProgressNotification`) are logged + skipped. They are idempotent and safe to drop — the
411///   next notification will replace them, and dropping here prevents transient load from killing a healthy session.
412/// - **Non-coalescible** actions (every other variant) are treated as fatal. If these cannot be delivered the channel
413///   is genuinely wedged.
414fn 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
435/// Returns `true` for actions that are safe to drop when the action channel
436/// is transiently full.
437fn is_coalescible(action: &SessionPixAction) -> bool {
438    matches!(action, SessionPixAction::ProgressNotification)
439}
440
441// ---------------------------------------------------------------------------
442// Tests
443// ---------------------------------------------------------------------------
444
445#[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    /// The first batch's census and `requested` count survive a Session torn down before its worker
464    /// is ever scheduled.
465    ///
466    /// `spawn_supervisor_worker` returns as soon as the task is queued, and establishment can still
467    /// fail after that — rolling the slot back and releasing the telemetry. A release that found
468    /// nothing published would suppress the worker's first publish when it finally ran, losing the
469    /// allocation this Session had already made.
470    #[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        // Nothing has been polled yet: the census must already be published.
479        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        // Tear it down in that same window.
488        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    /// See the identically-named helper in [`super::supervisor`] for why the surplus is non-zero.
526    fn dims() -> PixParams {
527        PixParams::try_new(10, 5, 7, crate::types::LOCAL_PIX_SUITE).expect("test dimensions must be valid")
528    }
529
530    /// Wait for `condition` to hold, failing the test if it never does.
531    ///
532    /// The worker is a detached task, so every assertion about its effects is really an assertion
533    /// that it has been scheduled. A fixed sleep encodes a guess about the scheduler, and the guess
534    /// is wrong under CI load or on a busy single-core runner — the test then fails with nothing
535    /// wrong in the code. Polling asserts the same property and can only fail if it genuinely never
536    /// happens, which is why the deadline is generous: it is reached only on a real failure.
537    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    /// `max_predeposit_packets = 0` must reach the gate as a zero budget.
567    ///
568    /// The budget is computed here, not in the gate, and nothing else exercises that line with a
569    /// zero cap — every other config in the tree sets a four-figure allowance. A `max(1, ..)` or an
570    /// off-by-one creeping into it would hand an unfunded Entry service that the Exit had
571    /// explicitly declined to offer, and the gate's own tests would still pass.
572    #[tokio::test]
573    async fn zero_predeposit_config_reaches_the_gate_as_strict_prepay() {
574        let cfg = SupervisorConfig {
575            max_predeposit_packets: 0,
576            // Fill the funded ceiling immediately before recovery. `Recovered` itself must reset
577            // it even if the final progress snapshot was dropped.
578            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        // What the action driver does when it carries out `ReleaseService`.
591        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    /// The configured cap can only lower the predeposit budget, never raise it.
601    ///
602    /// The other half of the same `min`: `dims()` is 10 × 5, so the dimensions bound the budget at 49
603    /// however generous the configuration is. Pinned alongside the zero case so the line is covered
604    /// from both directions rather than only where the cap happens to win.
605    #[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 the action receiver — worker should detect and close.
633        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        // The receiver remains alive but deliberately unread, modeling an action driver blocked on
679        // RequestSsa I/O. Cryptographic recovery alone must keep the gate funded for the bounded
680        // predecessor tail.
681        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        // The first successor-bound share is the actual FIFO boundary. Even though that successor
696        // is unfunded, the worker must synchronously consume the predecessor receipt and restore
697        // strict prepay; waiting for the stalled action driver would leak new service.
698        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        // Consume initial RequestSsa.
727        let _initial = tokio::time::timeout(Duration::from_secs(1), action_rx.recv())
728            .await
729            .expect("timeout")
730            .expect("action stream ended");
731
732        // Send SsaRequestSent via the handle — the worker should process it
733        // and produce no further actions (event is idempotent).
734        let id = SsaId::new(p, SsaIndex::new(1).unwrap());
735        handle.send_event(SessionPixEvent::SsaRequestSent(id)).await.unwrap();
736
737        // Give the worker time to process the event.
738        tokio::time::sleep(Duration::from_millis(50)).await;
739
740        // No extra actions should appear (idempotent event).
741        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        // Consume initial RequestSsa.
751        let _initial = tokio::time::timeout(Duration::from_secs(1), action_rx.recv())
752            .await
753            .expect("timeout")
754            .expect("action stream ended");
755
756        // Send action result for a failed RequestSsa — should trigger close.
757        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        // Consume initial RequestSsa.
783        let _initial = tokio::time::timeout(Duration::from_secs(1), action_rx.recv())
784            .await
785            .expect("timeout")
786            .expect("action stream ended");
787
788        // Tell the worker the request was sent so the commitment deadline starts.
789        let id = SsaId::new(p, SsaIndex::new(1).unwrap());
790        handle.send_event(SessionPixEvent::SsaRequestSent(id)).await.unwrap();
791
792        // Wait for the commitment deadline to expire.
793        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    // -----------------------------------------------------------------------
804    // H-04 / M-06: Worker termination and channel saturation
805    // -----------------------------------------------------------------------
806
807    #[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        // Clone the gate before dropping the handle so we can assert the worker
813        // poisoned it on exit.
814        let gate = handle.gate.clone();
815
816        // Drop the handle — last cmd_tx sender is dropped, cmd_rx yields None.
817        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        // Consume initial RequestSsa.
833        let _initial = tokio::time::timeout(Duration::from_secs(1), action_rx.recv())
834            .await
835            .expect("timeout")
836            .expect("action stream ended");
837
838        // Register the SSA so the commitment deadline starts.
839        let id = SsaId::new(p, SsaIndex::new(1).unwrap());
840        handle.send_event(SessionPixEvent::SsaRequestSent(id)).await.unwrap();
841
842        // Deadline expires → worker closes.  The Close action proves the worker
843        // processed the event and ran the termination path.
844        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    /// A `Close` the supervisor did not flag itself with must still fail closed.
858    ///
859    /// Several handlers return [`SessionPixAction::Close`] without setting `supervisor.closed` —
860    /// among them the two anti-abuse verdicts. A `dispatch` that reads only the flag sends the close
861    /// and then keeps running with the gate open, which is what this pins against. The foreign
862    /// pseudonym is the cheapest of those paths to reach: `on_ssa_request_sent` rejects it before it
863    /// looks anything up, and returns `InvalidTransition` without touching the flag.
864    #[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        // Consume the initial RequestSsa so the close below is unambiguous.
870        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 the action receiver — worker's next send_actions will fail.
905        drop(action_rx);
906
907        // Polled on the assertion itself rather than on a proxy, because the worker drops its
908        // command receiver only after poisoning the gate. The channel holds 64 and at most one send
909        // can land in the window between those two, so this cannot fill it.
910        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 the receiver so the channel is disconnected.
935        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    // -----------------------------------------------------------------------
954    // PD-07: Command-channel backpressure
955    // -----------------------------------------------------------------------
956
957    #[tokio::test]
958    async fn command_channel_backpressures_on_full() {
959        // Use a tiny channel (capacity 1) with no worker so events queue up.
960        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        // First send should succeed immediately.
971        handle.send_event(SessionPixEvent::SsaRequestSent(id)).await.unwrap();
972
973        // Second send should fail (channel full, no worker draining).
974        let result = tokio::time::timeout(
975            Duration::from_millis(50),
976            handle.send_event(SessionPixEvent::SsaRequestSent(id)),
977        )
978        .await;
979
980        // The send should be pending (not complete) since there's no worker
981        // to drain the channel. A timeout means it correctly backpressured.
982        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        // Fill the channel.
998        handle.send_event(SessionPixEvent::SsaRequestSent(id)).await.unwrap();
999
1000        // Spawn a task that will send a second event and wait.
1001        let parked = {
1002            let handle = handle.clone();
1003            tokio::spawn(async move { handle.send_event(SessionPixEvent::SsaRequestSent(id)).await })
1004        };
1005
1006        // Give the parked send a moment to register.
1007        tokio::time::sleep(Duration::from_millis(20)).await;
1008
1009        // Drain one item from the channel — the parked send should now complete.
1010        let drained = cmd_rx.recv().await;
1011        assert!(drained.is_ok(), "expected one command in the channel");
1012
1013        // The parked send should complete within a reasonable timeout.
1014        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    // -----------------------------------------------------------------------
1020    // PD-02: Action-channel Full handling
1021    // -----------------------------------------------------------------------
1022
1023    #[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        // Fill the channel so the next send hits TrySendError::Full.
1028        action_tx.try_send(SessionPixAction::ReleaseService).unwrap();
1029
1030        // Flood with coalescible actions — silently dropped, send_actions
1031        // returns true (session survives).
1032        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        // Non-coalescible action on the same full channel → returns false
1038        // (session must be terminated).
1039        assert!(!send_actions(
1040            &[SessionPixAction::Close(SessionPixCloseReason::CommitmentTimeout)],
1041            &action_tx
1042        ));
1043
1044        // Disconnected channel — also fatal. Use a separate channel so the
1045        // sender is still alive but the receiver is dropped.
1046        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    // ---------------------------------------------------------------
1052    // PIX fill
1053    // ---------------------------------------------------------------
1054
1055    /// Drains the action channel until a `SetFillRate` arrives, or the deadline passes.
1056    ///
1057    /// A funded cycle also produces `ReleaseService` and a `ProgressNotification` or two, so a test
1058    /// that read only the next action would be asserting on whichever one the worker happened to
1059    /// emit first.
1060    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    /// Funds cycle one through the handle, the way the action driver does.
1076    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    /// The planner is only useful if the worker actually wakes for it.
1089    ///
1090    /// Everything else about fill is tested against a supervisor driven by hand with explicit
1091    /// instants. This is the one property that cannot be: that the worker's own timer — which
1092    /// previously woke only for lifecycle deadlines, none of which is due within an hour of a cycle
1093    /// funding — now also wakes for the sampling interval and delivers what the planner decided.
1094    #[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        // Two sampling intervals, so the check spans a tick the planner could have emitted on. At
1100        // 200 ms the first tick had not happened yet, so the assertion held for a Session that fills
1101        // everything as readily as for one that fills nothing.
1102        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    /// The worker must deliver the zero, and deliver it ahead of the close it accompanies.
1117    ///
1118    /// `dispatch` stops applying actions at a `Close` and the driver breaks its loop on one, so a
1119    /// zero that arrived after would never be applied — the Session would be torn down with its fill
1120    /// stream still running at whatever rate it was last set to.
1121    #[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}