Skip to main content

hopr_transport/protocol/
surb_telemetry.rs

1//! Turns SURB round-trips into edge telemetry for the network graph.
2//!
3//! A SURB rides a forward path to reach its destination and carries a return path for the reply to
4//! come back on. When the reply arrives, that is proof both legs passed end to end -- evidence the
5//! graph already wants, produced by traffic a session was sending anyway. Unlike a probe it costs
6//! no extra packets and accrues at data rates, which is what lets a dead relayer be noticed in
7//! seconds rather than after a probe success rate has moved behind a path cache.
8//!
9//! # Why one layer owns both directions
10//!
11//! Minting and consuming happen at opposite ends of the pipeline, but only together do they mean
12//! anything: the mint says what was expected, the reply says what was observed. Wrapping both codec
13//! halves keeps the SURB-to-path association private to this module, so nothing below has to carry
14//! a path identity it has no other use for.
15
16use std::{
17    sync::{
18        Arc,
19        atomic::{AtomicU64, Ordering},
20    },
21    time::Duration,
22};
23
24use bytes::Bytes;
25use dashmap::DashMap;
26use hopr_api::{
27    graph::{ForwardAndReturnPath, MeasurableEdge, MeasurablePath, MeasurablePeer, NetworkGraphView, SurbTelemetry},
28    types::{
29        crypto::{prelude::*, types::OffchainPublicKey},
30        internal::prelude::*,
31    },
32};
33use hopr_crypto_packet::{HoprSurb, prelude::PacketSignals};
34use hopr_protocol_hopr::{IncomingPacket, IncomingPacketError, OutgoingPacket, PacketDecoder, PacketEncoder};
35
36/// Slots in a [`PathId`], and therefore the longest leg that can be reported.
37const PATH_ID_SLOTS: usize = 5;
38
39/// How long a minted SURB stays eligible to be observed coming back.
40///
41/// A SURB the replier never uses would otherwise sit in the pending map forever, so the expectation
42/// it raised is retired after this long. Generous relative to a round-trip, because expiring a SURB
43/// that is merely slow would count a success as a loss.
44const PENDING_SURB_TTL: Duration = Duration::from_secs(60);
45
46/// Expected/observed counts for one pair of legs, accumulated between flushes.
47///
48/// Both are monotonic within an interval, so recording is a pair of relaxed atomic adds on the
49/// packet hot path -- no lock, no allocation.
50#[derive(Debug, Default)]
51pub struct SurbRoundTripCounters {
52    expected: AtomicU64,
53    observed: AtomicU64,
54}
55
56impl SurbRoundTripCounters {
57    fn record_expected(&self, count: u64) {
58        self.expected.fetch_add(count, Ordering::Relaxed);
59    }
60
61    fn record_observed(&self, count: u64) {
62        self.observed.fetch_add(count, Ordering::Relaxed);
63    }
64
65    /// Reads both counts without resetting them.
66    fn peek(&self) -> (u64, u64) {
67        (
68            self.expected.load(Ordering::Relaxed),
69            self.observed.load(Ordering::Relaxed),
70        )
71    }
72
73    /// Takes both counts, resetting them to zero.
74    fn take(&self) -> (u64, u64) {
75        (
76            self.expected.swap(0, Ordering::Relaxed),
77            self.observed.swap(0, Ordering::Relaxed),
78        )
79    }
80}
81
82/// Round-trip counts keyed by the legs they were observed over.
83///
84/// Batching is not an optimisation here but a requirement: recording an edge takes a write lock on
85/// the whole graph, a packet carries several SURBs, and a session at 0.5 MB/s moves hundreds of
86/// packets a second -- reporting per event would contend the graph thousands of times a second.
87#[derive(Debug, Clone)]
88pub struct SurbRoundTripRegistry {
89    inner: Arc<DashMap<ForwardAndReturnPath, Arc<SurbRoundTripCounters>>>,
90    /// Destination each pair of legs leads to, so a collapse can name what to re-plan.
91    destinations: Arc<DashMap<ForwardAndReturnPath, OffchainPublicKey>>,
92    /// What each pair's recent flushes say about whether it still works.
93    silence: Arc<DashMap<ForwardAndReturnPath, Silence>>,
94    /// Flushes since each destination was last reported, so one is not re-planned repeatedly.
95    replanned: Arc<DashMap<OffchainPublicKey, u32>>,
96    /// Slot this node occupies, learned from the first pair recorded.
97    ///
98    /// A [`PathId`] is zero-padded with no length, and slot 0 is a legitimate index, so the end of
99    /// a leg cannot be found by looking for padding. It can be found by looking for *us*: every
100    /// reply leg terminates here. [`ME_SLOT_UNKNOWN`] means not yet known.
101    me_slot: Arc<AtomicU64>,
102}
103
104/// `me_slot` value meaning "this node's slot has not been learned yet".
105///
106/// Cannot collide with a real slot, unlike 0, which is both a legitimate index and what
107/// `AtomicU64::default()` would yield -- a derived `Default` would silently claim we sit in slot 0
108/// and make every reply leg look like it terminates at its padding.
109const ME_SLOT_UNKNOWN: u64 = u64::MAX;
110
111impl Default for SurbRoundTripRegistry {
112    fn default() -> Self {
113        Self {
114            inner: Default::default(),
115            destinations: Default::default(),
116            silence: Default::default(),
117            replanned: Default::default(),
118            me_slot: Arc::new(AtomicU64::new(ME_SLOT_UNKNOWN)),
119        }
120    }
121}
122
123/// Relayers carrying the reply leg: everything between the destination and ourselves.
124fn return_relayers(reply: &PathId, me_slot: u64) -> impl Iterator<Item = u64> + '_ {
125    reply.iter().skip(1).take_while(move |&&slot| slot != me_slot).copied()
126}
127
128/// Per-pair silence bookkeeping, carried between flushes.
129#[derive(Debug, Default, Clone, Copy)]
130struct Silence {
131    /// Consecutive flushes in which the pair minted SURBs and got nothing back.
132    runs: u32,
133    /// Whether a reply has ever come back over this pair.
134    ///
135    /// Load-bearing: it is what turns the signal from "silent" into "stopped working". A pair that
136    /// has never delivered may simply be too young -- a reply is credited to the flush it *arrives*
137    /// in, so the first flushes of a new pair legitimately show mints with no replies yet.
138    delivered: bool,
139}
140
141/// SURBs a pair must have minted in one flush before its silence counts as evidence.
142///
143/// Measured after a relayer was killed: the dead leg minted 2270 SURBs in the 5s that followed
144/// while returning none. A handful is noise; thousands is not.
145const MIN_EXPECTED_FOR_SILENCE: u64 = 20;
146
147/// Consecutive silent flushes before a pair that used to deliver is called dead.
148///
149/// Flushes are one second apart. Three was measured to be too few: it fired six times during
150/// healthy operation, once per flush, because a burst of mints can outrun its replies for a couple
151/// of seconds without anything being wrong. Five keeps detection well inside the fifteen-second
152/// budget -- the dead leg reads exactly zero from t+5s onward while its siblings keep returning.
153const SILENT_FLUSHES_BEFORE_DEGRADED: u32 = 5;
154
155/// Flushes a destination must wait before it can be re-planned again.
156///
157/// Silence accrues per pair of legs, but re-planning acts on the *destination*, and a destination
158/// carries several pairs at once. Re-arming each pair individually therefore still allows one
159/// destination to be re-planned every couple of seconds -- faster than a freshly chosen return path
160/// can be established and start delivering, so each re-plan destroys the candidate the previous one
161/// selected. Measured with the invalidation finally reaching the cache: fifteen re-plans during a
162/// thirty-second healthy baseline, which collapsed it from 100% to 0.1% arrival.
163///
164/// Long enough for a new path to prove itself (it needs [`SILENT_FLUSHES_BEFORE_DEGRADED`] flushes
165/// just to be judged), short enough to retry twice inside the recovery budget.
166const FLUSHES_BETWEEN_REPLANS: u32 = 8;
167
168impl SurbRoundTripRegistry {
169    fn counters(&self, paths: ForwardAndReturnPath) -> Arc<SurbRoundTripCounters> {
170        self.inner.entry(paths).or_default().value().clone()
171    }
172
173    /// Tells the registry which slot is ours, so a leg's relayers can be told from its padding.
174    pub fn note_me_slot(&self, slot: u64) {
175        self.me_slot.store(slot, Ordering::Relaxed);
176    }
177
178    /// Records that `count` SURBs were minted over these legs and are expected back.
179    pub fn record_expected(&self, paths: ForwardAndReturnPath, count: u64, destination: OffchainPublicKey) {
180        self.destinations.insert(paths, destination);
181        self.counters(paths).record_expected(count);
182    }
183
184    /// Destinations whose return path has been silent for long enough to act on.
185    ///
186    /// Deliberately keyed on *no reply at all* rather than on a delivery rate. A rate cannot
187    /// separate these cases: measured immediately after a kill, a healthy leg read 0.089 while the
188    /// dead one read 0.123, and replies straddling a flush boundary push a healthy leg above 1.
189    /// Only sustained silence distinguishes them, and it does so within seconds.
190    ///
191    /// The claim made is narrower than "this pair is silent": it is "this pair **used to deliver**
192    /// and has now stopped". Silence alone was measured to fire during healthy operation, because
193    /// a pair that has not yet returned its first reply is indistinguishable from a dead one.
194    pub fn degraded_destinations(&self) -> Vec<OffchainPublicKey> {
195        let mut degraded = Vec::new();
196
197        // Age the per-destination cooldowns once per flush, dropping those that have served out.
198        self.replanned.retain(|_, since| {
199            *since += 1;
200            *since < FLUSHES_BETWEEN_REPLANS
201        });
202
203        // Which destinations had *some* pair deliver in this flush.
204        //
205        // This is what makes silence mean anything. On its own, "minted and got nothing back" is
206        // produced identically by a dead relayer and by a peer with nothing to say -- keep-alives
207        // mint either way. Only a sibling pair still delivering to the same destination tells the
208        // two apart, and it does so without a threshold: if the peer had gone quiet, every pair
209        // would be silent together.
210        let delivering: std::collections::HashSet<OffchainPublicKey> = self
211            .inner
212            .iter()
213            .filter(|entry| entry.value().peek().1 > 0)
214            .filter_map(|entry| self.destinations.get(entry.key()).map(|d| *d))
215            .collect();
216
217        // Which relayers are demonstrably still carrying replies.
218        //
219        // A relayer that dies takes down every path through it and nothing else, so silence that
220        // correlates with one node is a dead relayer, while silence spread evenly over all of them
221        // is a quiet peer. Naming the node is also what lets the blame land on the edges that
222        // deserve it instead of on every edge the loop happened to touch.
223        // Without our own slot every leg's relayers are unknowable, and the corroboration step
224        // below is what keeps a quiet peer from being called a dead relay. Guessing here would
225        // trade a missed detection for a false one, so make no claim until the slot is known.
226        let me_slot = self.me_slot.load(Ordering::Relaxed);
227        if me_slot == ME_SLOT_UNKNOWN {
228            return degraded;
229        }
230
231        let delivering_relayers: std::collections::HashSet<u64> = self
232            .inner
233            .iter()
234            .filter(|entry| entry.value().peek().1 > 0)
235            .flat_map(|entry| return_relayers(&entry.key().reply, me_slot).collect::<Vec<_>>())
236            .collect();
237
238        // Destinations that used to deliver, have now gone silent, yet cannot be attributed because
239        // no sibling pair to them is delivering — the corroboration blind spot. Distinct from
240        // `degraded`: these are exactly the return paths that are failing but that the detector
241        // *cannot* act on (a single-relayer collapse looks identical to a quiet peer). Surfacing
242        // them is the diagnostic that was missing when this condition took the tunnel down.
243        let mut blind_spot: std::collections::HashSet<OffchainPublicKey> = std::collections::HashSet::new();
244
245        for entry in self.inner.iter() {
246            let paths = *entry.key();
247            let (expected, observed) = entry.value().peek();
248            let mut state = self.silence.entry(paths).or_default();
249
250            if observed > 0 {
251                // Delivering. Note that it ever worked, so future silence is meaningful.
252                state.delivered = true;
253                state.runs = 0;
254                continue;
255            }
256
257            if expected < MIN_EXPECTED_FOR_SILENCE {
258                // Too little went out to conclude anything. An idle pair is not a failing one.
259                state.runs = 0;
260                continue;
261            }
262
263            if !state.delivered {
264                // Never returned anything yet, so there is no "stopped" to observe -- only a pair
265                // too young to have been measured.
266                continue;
267            }
268
269            let Some(dest) = self.destinations.get(&paths).map(|d| *d) else {
270                continue;
271            };
272
273            // Corroboration: some other pair to this destination is still getting replies home, so
274            // the peer is demonstrably talking and this pair's silence is its own fault. The run
275            // resets when nothing corroborates, so `runs` counts consecutive flushes in which this
276            // pair was silent *while the peer was demonstrably answering elsewhere* -- not merely
277            // flushes in which it was quiet.
278            if !delivering.contains(&dest) {
279                // Used to deliver, now silent, and nothing else to this destination is answering:
280                // the blind spot. Cannot tell a dead return path from a peer with nothing to say.
281                blind_spot.insert(dest);
282                state.runs = 0;
283                continue;
284            }
285
286            // Sharpened: the peer is talking, and this pair carries at least one relayer that is
287            // not carrying replies anywhere else. Silence that every relayer shares is the peer
288            // being selective, not a relay failing.
289            if return_relayers(&paths.reply, me_slot).all(|r| delivering_relayers.contains(&r)) {
290                state.runs = 0;
291                continue;
292            }
293
294            state.runs += 1;
295            // Early warning: this pair used to deliver, is now silent, and a sibling to the same
296            // destination is still answering — so the silence is attributable and climbing toward a
297            // re-plan. Surfacing the run as it builds turns "suddenly re-planned" into a visible ramp.
298            tracing::debug!(
299                destination = %dest,
300                runs = state.runs,
301                threshold = SILENT_FLUSHES_BEFORE_DEGRADED,
302                expected,
303                "return pair silent while a sibling delivers; silence run climbing",
304            );
305            if state.runs >= SILENT_FLUSHES_BEFORE_DEGRADED {
306                // Re-arm rather than accumulate: re-planning the same destination on every
307                // subsequent flush churns its cached candidates instead of letting the new
308                // selection settle.
309                state.runs = 0;
310                // Only if this destination is not already serving out a cooldown: another of its
311                // pairs may have reported it moments ago, and re-planning again now would discard
312                // the candidate that re-plan just chose.
313                if !self.replanned.contains_key(&dest) {
314                    tracing::debug!(
315                        destination = %dest,
316                        runs = SILENT_FLUSHES_BEFORE_DEGRADED,
317                        "return path degraded: sustained silence corroborated by a delivering sibling; \
318                         marking destination for re-plan",
319                    );
320                    self.replanned.insert(dest, 0);
321                    degraded.push(dest);
322                }
323            }
324        }
325
326        // One line per flush, only when there is something to say. `blind_spot` is the actionable
327        // gap: return paths demonstrably failing that the detector cannot re-plan for want of a
328        // corroborating sibling — raise return-relayer diversity (see the path selector) to close it.
329        if !degraded.is_empty() || !blind_spot.is_empty() {
330            tracing::debug!(
331                degraded = degraded.len(),
332                blind_spot = blind_spot.len(),
333                blind_spot_destinations = ?blind_spot,
334                delivering_destinations = delivering.len(),
335                delivering_relayers = delivering_relayers.len(),
336                "surb return-path degradation scan",
337            );
338        }
339
340        degraded
341    }
342
343    /// Records that `count` replies arrived over these legs.
344    pub fn record_observed(&self, paths: ForwardAndReturnPath, count: u64) {
345        self.counters(paths).record_observed(count);
346    }
347
348    /// Takes every non-empty entry, resetting the counts.
349    pub fn drain(&self) -> Vec<(ForwardAndReturnPath, u64, u64)> {
350        self.inner
351            .iter()
352            .filter_map(|entry| {
353                let (expected, observed) = entry.value().take();
354                (expected > 0 || observed > 0).then(|| (*entry.key(), expected, observed))
355            })
356            .collect()
357    }
358}
359
360/// Resolves a node to the slot it occupies in a [`PathId`].
361///
362/// Taken as a function rather than as a graph so the codec decorator stays free of the graph's type
363/// parameters -- the packet pipeline builder is already deeply generic and has no graph handle of
364/// its own to thread through.
365pub type PathSlotResolver = Arc<dyn Fn(&OffchainPublicKey) -> Option<u64> + Send + Sync>;
366
367/// A resolver that places no node, so nothing is ever attributed.
368///
369/// Lets the decorator be installed unconditionally: with no graph to resolve against, every leg
370/// fails to build an id and is skipped before it reaches the pending map.
371pub fn no_path_slots() -> PathSlotResolver {
372    Arc::new(|_| None)
373}
374
375/// Legs each outstanding SURB was minted over, shared between the two codec halves.
376///
377/// Minting happens on the encoder and the reply arrives at the decoder, so this **must** be one map
378/// shared by both. Giving each half its own leaves every lookup missing and silently discards the
379/// entire observation side of the metric.
380pub type PendingLegs = moka::sync::Cache<HoprSurbId, ForwardAndReturnPath>;
381
382/// Builds the shared pending map, bounded by capacity and TTL.
383pub fn pending_legs(max_pending: u64) -> PendingLegs {
384    moka::sync::Cache::builder()
385        .max_capacity(max_pending)
386        .time_to_live(PENDING_SURB_TTL)
387        .build()
388}
389
390/// Reads path slots out of a network graph.
391pub fn path_slots_of<G>(graph: G) -> PathSlotResolver
392where
393    G: NetworkGraphView<NodeId = OffchainPublicKey> + Send + Sync + 'static,
394{
395    Arc::new(move |key| graph.path_slot(key))
396}
397
398/// Builds the [`PathId`] of a leg from the nodes it visits.
399///
400/// Returns `None` if any node is unknown to the graph or the leg is longer than a [`PathId`] can
401/// hold -- in both cases the id would name edges the round-trip did not use, which is worse than
402/// reporting nothing.
403fn path_id(slots: &PathSlotResolver, nodes: impl IntoIterator<Item = OffchainPublicKey>) -> Option<PathId> {
404    let mut id = [0u64; PATH_ID_SLOTS];
405    let mut len = 0;
406
407    for node in nodes {
408        if len == PATH_ID_SLOTS {
409            return None;
410        }
411        id[len] = slots(&node)?;
412        len += 1;
413    }
414
415    (len > 1).then_some(id)
416}
417
418/// Derives both legs of a round-trip from the routing that produced the SURB.
419///
420/// The forward leg starts at us and ends at the destination; the reply leg starts at that same
421/// destination and ends back at us. Joining them at the destination is what lets the graph credit
422/// the whole loop, and it is why the forward path's last hop seeds the reply leg.
423fn round_trip_paths(
424    slots: &PathSlotResolver,
425    me: &OffchainPublicKey,
426    forward: &[OffchainPublicKey],
427    reply: &[OffchainPublicKey],
428) -> Option<ForwardAndReturnPath> {
429    let destination = *forward.last()?;
430
431    Some(ForwardAndReturnPath {
432        forward: path_id(slots, std::iter::once(*me).chain(forward.iter().copied()))?,
433        reply: path_id(slots, std::iter::once(destination).chain(reply.iter().copied()))?,
434    })
435}
436
437/// `record_edge` is generic over peer and path telemetry that a SURB observation does not carry.
438///
439/// These are uninhabited, so they satisfy the bounds without claiming a round-trip has neighbour or
440/// probe telemetry attached.
441#[derive(Debug, Clone)]
442pub enum NoPeerTelemetry {}
443
444impl MeasurablePeer for NoPeerTelemetry {
445    fn peer(&self) -> &OffchainPublicKey {
446        match *self {}
447    }
448
449    fn rtt(&self) -> Duration {
450        match *self {}
451    }
452}
453
454/// Counterpart to [`NoPeerTelemetry`] for the path half of `record_edge`.
455#[derive(Debug, Clone)]
456pub enum NoPathTelemetry {}
457
458impl MeasurablePath for NoPathTelemetry {
459    fn id(&self) -> &[u8] {
460        match *self {}
461    }
462
463    fn path(&self) -> &[u8] {
464        match *self {}
465    }
466
467    fn timestamp(&self) -> u128 {
468        match *self {}
469    }
470}
471
472/// Turns accumulated counts into graph observations.
473///
474/// Separate from the flush task itself so the batching can be tested without a timer.
475pub fn flush_into<G>(registry: &SurbRoundTripRegistry, graph: &G, timestamp: u128)
476where
477    G: hopr_api::graph::NetworkGraphUpdate,
478{
479    let (mut legs, mut total_expected, mut total_observed) = (0usize, 0u64, 0u64);
480
481    for (paths, expected, observed) in registry.drain() {
482        legs += 1;
483        total_expected += expected;
484        total_observed += observed;
485        // Per pair, at debug: the aggregate cannot answer whether a dead relayer's legs diverge
486        // from healthy ones, which is the property any trigger built on this signal depends on.
487        tracing::debug!(
488            forward = ?paths.forward,
489            reply = ?paths.reply,
490            expected,
491            observed,
492            "surb round-trip pair"
493        );
494        graph.record_edge::<NoPeerTelemetry, NoPathTelemetry>(MeasurableEdge::Surb(SurbTelemetry {
495            paths,
496            timestamp,
497            expected,
498            observed,
499        }));
500    }
501
502    {
503        // One aggregate line per interval rather than one per pair of legs.
504        //
505        // DIAGNOSTIC: at `info` while the recovery gap is under investigation. This is the pair of
506        // numbers that separates "the counterparty never got SURBs" from "it got them, replied, and
507        // the replies did not arrive" -- the two explanations left for a return path carrying
508        // packets while the application receives almost nothing. Drop back to `debug` once settled.
509        tracing::info!(
510            legs,
511            expected = total_expected,
512            observed = total_observed,
513            "surb round-trip flush tick"
514        );
515    }
516}
517
518/// Wraps a codec so the SURBs it mints and consumes are counted per pair of legs.
519///
520/// Encoding and decoding are otherwise passed straight through; a failure to attribute a round-trip
521/// never fails a packet.
522#[derive(Clone)]
523pub struct SurbTelemetryCodec<C> {
524    inner: C,
525    me: OffchainPublicKey,
526    slots: PathSlotResolver,
527    registry: SurbRoundTripRegistry,
528    /// Legs each outstanding SURB was minted over.
529    ///
530    /// Bounded by capacity and TTL rather than by replies arriving, so a peer that never replies
531    /// cannot grow it without limit. Shared with the other codec half -- see [`PendingLegs`].
532    pending: PendingLegs,
533}
534
535impl<C: std::fmt::Debug> std::fmt::Debug for SurbTelemetryCodec<C> {
536    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
537        // The slot resolver is a closure and has nothing meaningful to show.
538        f.debug_struct("SurbTelemetryCodec")
539            .field("inner", &self.inner)
540            .field("me", &self.me)
541            .finish_non_exhaustive()
542    }
543}
544
545impl<C> SurbTelemetryCodec<C> {
546    /// Wraps `inner`, resolving path slots with `slots` and accumulating into `registry`.
547    pub fn new(
548        inner: C,
549        me: OffchainPublicKey,
550        slots: PathSlotResolver,
551        registry: SurbRoundTripRegistry,
552        pending: PendingLegs,
553    ) -> Self {
554        Self {
555            inner,
556            me,
557            slots,
558            registry,
559            pending,
560        }
561    }
562
563    /// Associates freshly minted SURBs with the legs they were minted over.
564    ///
565    /// Opener order is significant, so the ids zip positionally with the return paths that produced
566    /// them.
567    fn on_minted(&self, routing: &ResolvedTransportRouting<HoprSurb>, minted: &[HoprSurbId]) {
568        let ResolvedTransportRouting::Forward {
569            forward_path,
570            return_paths,
571            ..
572        } = routing
573        else {
574            return;
575        };
576
577        let forward: Vec<_> = forward_path.transport_path().iter().copied().collect();
578        let Some(destination) = forward.last().copied() else {
579            return;
580        };
581
582        if let Some(slot) = (self.slots)(&self.me) {
583            self.registry.note_me_slot(slot);
584        }
585
586        tracing::debug!(
587            minted = minted.len(),
588            return_paths = return_paths.len(),
589            forward_hops = forward.len(),
590            "surb mint seen by telemetry"
591        );
592
593        for (surb_id, return_path) in minted.iter().zip(return_paths.iter()) {
594            let reply: Vec<_> = return_path.transport_path().iter().copied().collect();
595            let Some(paths) = round_trip_paths(&self.slots, &self.me, &forward, &reply) else {
596                tracing::debug!(reply_hops = reply.len(), "surb round-trip legs did not resolve");
597                continue;
598            };
599
600            self.registry.record_expected(paths, 1, destination);
601            self.pending.insert(*surb_id, paths);
602        }
603    }
604
605    /// Credits the legs a reply came back on.
606    fn on_replied(&self, surb_id: &HoprSurbId) {
607        // A SURB is single-use, so the association is consumed with it.
608        match self.pending.remove(surb_id) {
609            Some(paths) => self.registry.record_observed(paths, 1),
610            None => tracing::debug!("reply on a surb with no pending legs"),
611        }
612    }
613}
614
615impl<C> PacketEncoder for SurbTelemetryCodec<C>
616where
617    C: PacketEncoder,
618{
619    type Error = C::Error;
620
621    fn encode_packet<T: AsRef<[u8]> + Send + 'static, S: Into<PacketSignals> + Send + 'static>(
622        &self,
623        data: T,
624        routing: ResolvedTransportRouting<HoprSurb>,
625        signals: S,
626    ) -> Result<OutgoingPacket, <Self as PacketEncoder>::Error> {
627        let packet = self.inner.encode_packet(data, routing.clone(), signals)?;
628        self.on_minted(&routing, &packet.minted_surbs);
629        Ok(packet)
630    }
631
632    fn encode_acknowledgements(
633        &self,
634        acks: &[VerifiedAcknowledgement],
635        destination: &OffchainPublicKey,
636    ) -> Result<OutgoingPacket, <Self as PacketEncoder>::Error> {
637        self.inner.encode_acknowledgements(acks, destination)
638    }
639}
640
641impl<C> PacketDecoder for SurbTelemetryCodec<C>
642where
643    C: PacketDecoder,
644{
645    type Error = C::Error;
646
647    fn decode(
648        &self,
649        sender: PeerId,
650        data: Bytes,
651    ) -> Result<IncomingPacket, IncomingPacketError<<Self as PacketDecoder>::Error>> {
652        let packet = self.inner.decode(sender, data)?;
653
654        if let IncomingPacket::Final(f) = &packet {
655            tracing::debug!(on_surb = f.replied_on_surb.is_some(), "final packet decoded");
656        }
657
658        if let IncomingPacket::Final(final_packet) = &packet
659            && let Some(surb_id) = final_packet.replied_on_surb
660        {
661            self.on_replied(&surb_id);
662        }
663
664        Ok(packet)
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use anyhow::Context;
671    use hopr_api::{
672        graph::NetworkGraphWrite,
673        types::{
674            crypto::{
675                crypto_traits::Randomizable,
676                prelude::{ChainKeypair, Keypair, OffchainKeypair},
677            },
678            primitive::primitives::Address,
679        },
680    };
681    use hopr_network_graph::petgraph::ChannelGraph;
682
683    use super::*;
684
685    /// The trait impls are pass-throughs, so the tests drive the attribution logic directly.
686    fn recorder(graph: ChannelGraph) -> SurbTelemetryCodec<()> {
687        let me = *graph.identity();
688        SurbTelemetryCodec::new(
689            (),
690            me,
691            path_slots_of(graph),
692            SurbRoundTripRegistry::default(),
693            pending_legs(128),
694        )
695    }
696
697    fn surb_id(seed: u8) -> HoprSurbId {
698        [seed; SURB_ID_SIZE]
699    }
700
701    /// A cluster of `me` plus `count` peers, all known to the graph.
702    fn address() -> Address {
703        ChainKeypair::random().public().to_address()
704    }
705
706    fn graph_with(count: usize) -> (ChannelGraph, OffchainPublicKey, Vec<OffchainPublicKey>) {
707        let me = *OffchainKeypair::random().public();
708        let graph = ChannelGraph::new(me);
709
710        let peers = (0..count)
711            .map(|_| {
712                let key = *OffchainKeypair::random().public();
713                graph.add_node(key);
714                key
715            })
716            .collect();
717
718        (graph, me, peers)
719    }
720
721    #[test]
722    fn drain_should_report_nothing_before_anything_is_recorded() {
723        assert!(SurbRoundTripRegistry::default().drain().is_empty());
724    }
725
726    #[test]
727    fn drain_should_take_the_counts_and_leave_the_entry_empty() {
728        let (_graph, _me, peers) = graph_with(1);
729        let registry = registry_at_slot_zero();
730        let paths = ForwardAndReturnPath {
731            forward: [0, 1, 0, 0, 0],
732            reply: [1, 0, 0, 0, 0],
733        };
734
735        registry.record_expected(paths, 3, peers[0]);
736        registry.record_observed(paths, 2);
737
738        assert_eq!(vec![(paths, 3, 2)], registry.drain());
739        // Counts belong to the interval that produced them, so a second flush must not re-report.
740        assert!(registry.drain().is_empty());
741    }
742
743    #[test]
744    fn round_trip_paths_should_join_the_legs_at_the_destination() -> anyhow::Result<()> {
745        let (graph, me, peers) = graph_with(1);
746        let destination = peers[0];
747
748        let paths = round_trip_paths(&path_slots_of(graph.clone()), &me, &[destination], &[me])
749            .context("both nodes are in the graph")?;
750
751        let me_slot = graph.path_slot(&me).context("self is in the graph")?;
752        let dest_slot = graph.path_slot(&destination).context("destination is in the graph")?;
753
754        // The forward leg starts at us and ends at the destination; the reply leg starts at that
755        // same destination, which is what lets the graph credit the loop as one continuous walk.
756        assert_eq!([me_slot, dest_slot, 0, 0, 0], paths.forward);
757        assert_eq!([dest_slot, me_slot, 0, 0, 0], paths.reply);
758        Ok(())
759    }
760
761    #[test]
762    fn round_trip_paths_should_be_none_when_a_node_is_unknown_to_the_graph() {
763        let (graph, me, _) = graph_with(0);
764        let stranger = *OffchainKeypair::random().public();
765
766        // Reporting an id built from a node the graph cannot place would credit whichever edges the
767        // wrong slots happened to name.
768        assert!(round_trip_paths(&path_slots_of(graph), &me, &[stranger], &[me]).is_none());
769    }
770
771    #[test]
772    fn round_trip_paths_should_be_none_for_a_leg_too_long_to_identify() {
773        let (graph, me, peers) = graph_with(5);
774        let forward: Vec<_> = peers.clone();
775
776        // A `PathId` holds five slots; a longer leg cannot be named without dropping hops.
777        assert!(round_trip_paths(&path_slots_of(graph), &me, &forward, &[me]).is_none());
778    }
779
780    #[test]
781    fn minting_a_surb_should_raise_an_expectation_over_the_legs_it_was_minted_on() -> anyhow::Result<()> {
782        let (graph, me, peers) = graph_with(1);
783        let destination = peers[0];
784        let recorder = recorder(graph);
785
786        let routing = ResolvedTransportRouting::Forward {
787            pseudonym: HoprPseudonym::random(),
788            forward_path: ValidatedPath::direct(destination, address()),
789            return_paths: vec![ValidatedPath::direct(me, address())],
790        };
791
792        recorder.on_minted(&routing, &[surb_id(1)]);
793
794        let drained = recorder.registry.drain();
795        assert_eq!(1, drained.len());
796        let (_, expected, observed) = drained[0];
797        assert_eq!(1, expected);
798        assert_eq!(0, observed, "nothing has come back yet");
799        Ok(())
800    }
801
802    #[test]
803    fn a_reply_should_be_credited_to_the_legs_its_surb_was_minted_on() -> anyhow::Result<()> {
804        let (graph, me, peers) = graph_with(1);
805        let destination = peers[0];
806        let recorder = recorder(graph);
807
808        let routing = ResolvedTransportRouting::Forward {
809            pseudonym: HoprPseudonym::random(),
810            forward_path: ValidatedPath::direct(destination, address()),
811            return_paths: vec![ValidatedPath::direct(me, address())],
812        };
813
814        recorder.on_minted(&routing, &[surb_id(1)]);
815        recorder.on_replied(&surb_id(1));
816
817        let drained = recorder.registry.drain();
818        assert_eq!(1, drained.len());
819        let (_, expected, observed) = drained[0];
820        assert_eq!((1, 1), (expected, observed));
821        Ok(())
822    }
823
824    #[test]
825    fn a_surb_should_only_be_credited_once() -> anyhow::Result<()> {
826        let (graph, me, peers) = graph_with(1);
827        let destination = peers[0];
828        let recorder = recorder(graph);
829
830        let routing = ResolvedTransportRouting::Forward {
831            pseudonym: HoprPseudonym::random(),
832            forward_path: ValidatedPath::direct(destination, address()),
833            return_paths: vec![ValidatedPath::direct(me, address())],
834        };
835
836        recorder.on_minted(&routing, &[surb_id(1)]);
837        recorder.on_replied(&surb_id(1));
838        // A SURB is single-use; a second reply on it cannot be genuine, and counting it would push
839        // the delivery ratio above what was actually expected.
840        recorder.on_replied(&surb_id(1));
841
842        let (_, expected, observed) = recorder.registry.drain()[0];
843        assert_eq!((1, 1), (expected, observed));
844        Ok(())
845    }
846
847    #[test]
848    fn flushing_should_leave_nothing_behind_to_report_twice() -> anyhow::Result<()> {
849        let (graph, me, peers) = graph_with(1);
850        let destination = peers[0];
851        let recorder = recorder(graph.clone());
852
853        let routing = ResolvedTransportRouting::Forward {
854            pseudonym: HoprPseudonym::random(),
855            forward_path: ValidatedPath::direct(destination, address()),
856            return_paths: vec![ValidatedPath::direct(me, address())],
857        };
858        recorder.on_minted(&routing, &[surb_id(1)]);
859        recorder.on_replied(&surb_id(1));
860
861        flush_into(&recorder.registry, &graph, 0);
862
863        // Counts belong to the interval that produced them; carrying them into the next flush would
864        // keep reporting a round-trip that happened once as though it kept happening.
865        assert!(recorder.registry.drain().is_empty());
866        Ok(())
867    }
868
869    #[test]
870    fn a_reply_should_be_credited_when_the_halves_are_separate_instances() -> anyhow::Result<()> {
871        // Regression: the pipeline wraps the encoder and the decoder in *separate* instances, so a
872        // pending map built per-instance leaves every lookup missing and silently discards the
873        // entire observation side. Every other test here drives one instance and cannot see it.
874        let (graph, me, peers) = graph_with(1);
875        let destination = peers[0];
876        let registry = registry_at_slot_zero();
877        let pending = pending_legs(128);
878
879        let minting = SurbTelemetryCodec::new((), me, path_slots_of(graph.clone()), registry.clone(), pending.clone());
880        let observing = SurbTelemetryCodec::new((), me, path_slots_of(graph), registry.clone(), pending);
881
882        minting.on_minted(
883            &ResolvedTransportRouting::Forward {
884                pseudonym: HoprPseudonym::random(),
885                forward_path: ValidatedPath::direct(destination, address()),
886                return_paths: vec![ValidatedPath::direct(me, address())],
887            },
888            &[surb_id(1)],
889        );
890        observing.on_replied(&surb_id(1));
891
892        let (_, expected, observed) = registry.drain()[0];
893        assert_eq!(
894            (1, 1),
895            (expected, observed),
896            "the reply must reach the legs the mint recorded"
897        );
898        Ok(())
899    }
900
901    /// One flush interval, in the order the flush task runs it: detect, then drain.
902    ///
903    /// Draining matters to these tests -- without it a single reply stays visible forever and
904    /// silence can never be observed at all.
905    fn flush(registry: &SurbRoundTripRegistry) -> Vec<OffchainPublicKey> {
906        let degraded = registry.degraded_destinations();
907        registry.drain();
908        degraded
909    }
910
911    /// A registry that already knows its own slot.
912    ///
913    /// The `legs()`/`heartbeat()` fixtures put us in slot 0, which the live codec would learn from
914    /// the graph on the first mint. Stating it here keeps the fixtures' assumption explicit rather
915    /// than leaning on whatever the field happens to be initialised to.
916    fn registry_at_slot_zero() -> SurbRoundTripRegistry {
917        let registry = SurbRoundTripRegistry::default();
918        registry.note_me_slot(0);
919        registry
920    }
921
922    /// A pair that mints steadily and returns replies -- the healthy case.
923    fn deliver(registry: &SurbRoundTripRegistry, paths: ForwardAndReturnPath, destination: OffchainPublicKey) {
924        registry.record_expected(paths, MIN_EXPECTED_FOR_SILENCE, destination);
925        registry.record_observed(paths, 1);
926    }
927
928    /// A pair that mints steadily and returns nothing.
929    fn mint_only(registry: &SurbRoundTripRegistry, paths: ForwardAndReturnPath, destination: OffchainPublicKey) {
930        registry.record_expected(paths, MIN_EXPECTED_FOR_SILENCE, destination);
931    }
932
933    /// A sibling pair to the same destination that keeps answering.
934    ///
935    /// Silence is only actionable against a peer that is demonstrably still talking, so any test
936    /// of the silence logic has to keep one pair delivering or nothing can ever fire.
937    fn heartbeat() -> ForwardAndReturnPath {
938        ForwardAndReturnPath {
939            forward: [0, 3, 2, 0, 0],
940            reply: [2, 3, 0, 0, 0],
941        }
942    }
943
944    /// me(0) -> relay(1) -> dest(2), and back dest(2) -> relay(1) -> me(0).
945    ///
946    /// The reply leg carries a real intermediate relayer: a zero-hop return has no relay to blame,
947    /// so silence over it can never name one.
948    fn legs() -> ForwardAndReturnPath {
949        ForwardAndReturnPath {
950            forward: [0, 1, 2, 0, 0],
951            reply: [2, 1, 0, 0, 0],
952        }
953    }
954
955    /// Every relayer identity in a leg is read relative to our own slot, so until that slot is
956    /// known there is nothing to read them against.
957    ///
958    /// Regression: a derived `Default` initialised `me_slot` to 0, which is a legitimate slot
959    /// rather than a sentinel. A node that is not in slot 0 would then take the zero *padding* at
960    /// the end of every reply leg to be itself, mis-attributing the relayers each leg carries and
961    /// feeding the corroboration gate a set it never observed.
962    #[test]
963    fn silence_should_not_be_attributed_before_our_own_slot_is_known() {
964        let (graph, me, peers) = graph_with(1);
965        let destination = peers[0];
966        let registry = SurbRoundTripRegistry::default();
967        let paths = legs();
968
969        // The exact sequence that names a destination once the slot is known (see the test below),
970        // run for longer than it would take, yields nothing while the slot is unknown.
971        deliver(&registry, paths, destination);
972        assert!(flush(&registry).is_empty());
973        for _ in 0..SILENT_FLUSHES_BEFORE_DEGRADED + 2 {
974            mint_only(&registry, paths, destination);
975            deliver(&registry, heartbeat(), destination);
976            assert!(
977                flush(&registry).is_empty(),
978                "no relayer can be identified without our own slot, so nothing may be blamed"
979            );
980        }
981
982        // Learning the slot makes the same evidence actionable.
983        registry.note_me_slot(0);
984        deliver(&registry, paths, destination);
985        assert!(flush(&registry).is_empty());
986        for _ in 1..SILENT_FLUSHES_BEFORE_DEGRADED {
987            mint_only(&registry, paths, destination);
988            deliver(&registry, heartbeat(), destination);
989            assert!(flush(&registry).is_empty());
990        }
991        mint_only(&registry, paths, destination);
992        deliver(&registry, heartbeat(), destination);
993        assert_eq!(vec![destination], flush(&registry));
994
995        let _ = (graph, me);
996    }
997
998    #[test]
999    fn sustained_silence_should_name_the_destination_to_replan() {
1000        let (graph, me, peers) = graph_with(1);
1001        let destination = peers[0];
1002        let registry = registry_at_slot_zero();
1003        let paths = legs();
1004
1005        // First establish that the pair works. Silence only means something against that.
1006        deliver(&registry, paths, destination);
1007        assert!(flush(&registry).is_empty());
1008
1009        // Then it goes quiet while still minting, with a sibling still answering to prove the
1010        // peer is talking. One quiet interval is not a dead path, which is why the gate counts runs.
1011        for _ in 1..SILENT_FLUSHES_BEFORE_DEGRADED {
1012            mint_only(&registry, paths, destination);
1013            deliver(&registry, heartbeat(), destination);
1014            assert!(flush(&registry).is_empty());
1015        }
1016        mint_only(&registry, paths, destination);
1017        deliver(&registry, heartbeat(), destination);
1018        assert_eq!(vec![destination], flush(&registry));
1019        let _ = (graph, me);
1020    }
1021
1022    /// End-to-end through the extracted flush tick: a silent return path drives re-plan → refill and
1023    /// marks the balancer's estimate stale — **while the balancer's SURB supply reads healthy**.
1024    ///
1025    /// This is the seam the component tests leave open: detection (`degraded_destinations`),
1026    /// sequencing (`ReturnPathEpisodes::tick`) and the balancer's degraded flag are each tested in
1027    /// isolation, but nothing wires the real registry through [`run_flush_tick`] into a real
1028    /// [`BalancerStateValues`]. It also pins the incident's supply-vs-delivery separation
1029    /// (assertion #3): the buffer sits *at target* the whole time, so the signal that fires is a
1030    /// return-path *delivery* signal, not SURB-distress.
1031    #[tokio::test]
1032    async fn a_silent_return_path_drives_replan_then_refill_and_marks_the_balancer_stale() {
1033        use hopr_transport_session::{BalancerStateValues, SurbBalancerConfig};
1034
1035        use crate::protocol::return_path_recovery::{RecoveryStep, ReturnPathEpisodes, run_flush_tick};
1036
1037        let (graph, _me, peers) = graph_with(1);
1038        let destination = peers[0];
1039        let registry = registry_at_slot_zero();
1040        let paths = legs();
1041        let grace = Duration::from_secs(10);
1042        let mut episodes = ReturnPathEpisodes::new(grace);
1043
1044        // A session whose SURB *supply* is healthy: a target is set and the buffer sits at it. If the
1045        // return-path signal fires against this, it cannot be a supply/distress signal.
1046        let balancer = BalancerStateValues::new(SurbBalancerConfig {
1047            target_surb_buffer_size: 1000,
1048            sustain_on_return_path_loss: true,
1049            ..Default::default()
1050        });
1051        balancer.buffer_level.store(1000, Ordering::Relaxed);
1052        assert!(!balancer.is_disabled(), "precondition: SURB supply is enabled");
1053        assert!(
1054            !balancer.return_path_estimate_is_stale(),
1055            "precondition: nothing has marked the return path yet"
1056        );
1057
1058        let replans = std::cell::Cell::new(0usize);
1059        let refills = std::cell::Cell::new(0usize);
1060
1061        // Establish the pair works; silence only means something against a pair that once delivered.
1062        deliver(&registry, paths, destination);
1063        let first = run_flush_tick(
1064            &registry,
1065            &graph,
1066            0,
1067            &mut episodes,
1068            |_d| async {
1069                replans.set(replans.get() + 1);
1070                1usize
1071            },
1072            |_d| async {
1073                refills.set(refills.get() + 1);
1074                balancer.mark_return_path_degraded(grace);
1075                1usize
1076            },
1077        )
1078        .await;
1079        assert!(first.is_empty(), "a freshly delivering pair is not degraded");
1080        assert_eq!((replans.get(), refills.get()), (0, 0), "nothing to recover yet");
1081
1082        // Then it goes quiet while a sibling keeps answering, until the tick names it and recovers.
1083        let mut steps = Vec::new();
1084        for _ in 0..SILENT_FLUSHES_BEFORE_DEGRADED {
1085            mint_only(&registry, paths, destination);
1086            deliver(&registry, heartbeat(), destination);
1087            steps = run_flush_tick(
1088                &registry,
1089                &graph,
1090                0,
1091                &mut episodes,
1092                |_d| async {
1093                    replans.set(replans.get() + 1);
1094                    1usize
1095                },
1096                |_d| async {
1097                    refills.set(refills.get() + 1);
1098                    balancer.mark_return_path_degraded(grace);
1099                    1usize
1100                },
1101            )
1102            .await;
1103            if !steps.is_empty() {
1104                break;
1105            }
1106        }
1107
1108        // Re-plan happened first and, because it moved traffic, a refill followed — in that order.
1109        assert!(
1110            matches!(
1111                steps.as_slice(),
1112                [
1113                    RecoveryStep::Replanned { destination: d1, moved: 1 },
1114                    RecoveryStep::Refilled { destination: d2, sessions: 1 },
1115                ] if *d1 == destination && *d2 == destination
1116            ),
1117            "expected re-plan then refill for the silent destination, got {steps:?}"
1118        );
1119        assert_eq!(refills.get(), 1, "exactly one refill, behind the re-plan");
1120
1121        // The refill marked the balancer: the estimate is now stale even though supply is healthy.
1122        assert!(
1123            balancer.return_path_estimate_is_stale(),
1124            "the refill must mark the return path degraded"
1125        );
1126        assert!(
1127            !balancer.is_disabled(),
1128            "supply-vs-delivery: SURB supply is still healthy…"
1129        );
1130        assert_eq!(
1131            balancer.buffer_level.load(Ordering::Relaxed),
1132            1000,
1133            "…the buffer never left its target — the signal is delivery, not distress"
1134        );
1135
1136        // Recovery: a reply arrives, so detection stops naming the destination and no further
1137        // recovery is driven.
1138        deliver(&registry, paths, destination);
1139        let recovered = run_flush_tick(
1140            &registry,
1141            &graph,
1142            0,
1143            &mut episodes,
1144            |_d| async {
1145                replans.set(replans.get() + 1);
1146                1usize
1147            },
1148            |_d| async {
1149                refills.set(refills.get() + 1);
1150                balancer.mark_return_path_degraded(grace);
1151                1usize
1152            },
1153        )
1154        .await;
1155        assert!(
1156            recovered.is_empty(),
1157            "once a reply arrives the return path is no longer named silent, got {recovered:?}"
1158        );
1159        assert_eq!(refills.get(), 1, "no second refill after the path recovered");
1160    }
1161
1162    /// Several pairs lead to one destination, but re-planning acts on the destination.
1163    ///
1164    /// Regression: silence accrues per pair, so pairs re-armed independently and between them kept
1165    /// re-planning the same destination every couple of seconds. With the invalidation finally
1166    /// reaching the path cache, that destroyed each freshly chosen return path before it could
1167    /// deliver -- a healthy baseline measured 0.1% arrival with fifteen re-plans inside it.
1168    ///
1169    /// The two pairs are staggered so they come due on *different* flushes; collapsing duplicates
1170    /// within one flush was already handled and is not what failed.
1171    #[test]
1172    fn one_destination_should_not_be_replanned_again_while_a_new_path_is_settling() {
1173        let (graph, me, peers) = graph_with(2);
1174        let destination = peers[0];
1175        let registry = registry_at_slot_zero();
1176
1177        let first = legs();
1178        let second = ForwardAndReturnPath {
1179            forward: [0, 4, 2, 0, 0],
1180            reply: [2, 4, 0, 0, 0],
1181        };
1182        assert_ne!(
1183            first, second,
1184            "the two pairs must be distinct for this to test anything"
1185        );
1186
1187        deliver(&registry, first, destination);
1188        deliver(&registry, second, destination);
1189        assert!(flush(&registry).is_empty());
1190
1191        // `first` goes quiet immediately; `second` keeps answering for three more flushes, so its
1192        // own silence comes due well after the first re-plan.
1193        for _ in 1..SILENT_FLUSHES_BEFORE_DEGRADED {
1194            mint_only(&registry, first, destination);
1195            deliver(&registry, second, destination);
1196            assert!(flush(&registry).is_empty());
1197        }
1198        mint_only(&registry, first, destination);
1199        deliver(&registry, second, destination);
1200        assert_eq!(
1201            vec![destination],
1202            flush(&registry),
1203            "the first silence must be acted on"
1204        );
1205
1206        // Now both stay silent. Suppression still resets the pair's counter, so it comes due
1207        // again and again; what must be bounded is how often the *destination* is acted on.
1208        const WINDOW: u32 = 4 * SILENT_FLUSHES_BEFORE_DEGRADED;
1209        let mut reports = 0;
1210        for _ in 0..WINDOW {
1211            mint_only(&registry, first, destination);
1212            mint_only(&registry, second, destination);
1213            deliver(&registry, heartbeat(), destination);
1214            reports += flush(&registry).len();
1215        }
1216
1217        // Two pairs coming due every five flushes would otherwise report roughly eight times in
1218        // this window; measured in the cluster, fifteen re-plans in thirty flushes was enough to
1219        // hold a healthy session at 0.1% arrival.
1220        // An absolute bound, deliberately not derived from `FLUSHES_BETWEEN_REPLANS` -- a limit
1221        // computed from the constant under test moves with it and can never fail. Suppression
1222        // resets the pair, so it comes due every five flushes; the cooldown lets roughly every
1223        // other one through, which over twenty flushes is at most three.
1224        assert!(
1225            reports <= 3,
1226            "a destination must not be re-planned faster than a new path can settle: {reports} re-plans in {WINDOW} \
1227             flushes"
1228        );
1229
1230        let _ = (graph, me);
1231    }
1232
1233    /// A relayer still carrying replies elsewhere is not the one at fault.
1234    ///
1235    /// A dead relayer takes down every path through it and nothing else, so blame belongs to a node
1236    /// appearing only in silent legs. One demonstrably delivering on another pair is alive, and the
1237    /// silence has some other cause -- selective traffic, or a fault further along the leg.
1238    #[test]
1239    fn a_relayer_still_delivering_elsewhere_should_not_be_blamed() {
1240        let (graph, me, peers) = graph_with(3);
1241        let destination = peers[0];
1242        let registry = registry_at_slot_zero();
1243
1244        // Two distinct pairs sharing return relay 1, differing only in their forward leg.
1245        let delivering = legs();
1246        let silent = ForwardAndReturnPath {
1247            forward: [0, 4, 2, 0, 0],
1248            reply: [2, 1, 0, 0, 0],
1249        };
1250        assert_ne!(delivering, silent, "the pairs must be distinct keys");
1251
1252        deliver(&registry, silent, destination);
1253        assert!(flush(&registry).is_empty());
1254
1255        // Relay 1 keeps carrying replies on the other pair, so it cannot be what is broken.
1256        for _ in 0..(3 * SILENT_FLUSHES_BEFORE_DEGRADED) {
1257            mint_only(&registry, silent, destination);
1258            deliver(&registry, delivering, destination);
1259            assert!(
1260                flush(&registry).is_empty(),
1261                "a relayer that is delivering elsewhere must not be blamed for this pair's silence"
1262            );
1263        }
1264
1265        let _ = (graph, me);
1266    }
1267
1268    /// Silence spread over every relayer is the peer, not a relay.
1269    #[test]
1270    fn silence_on_every_relayer_should_blame_none_of_them() {
1271        let (graph, me, peers) = graph_with(3);
1272        let destination = peers[0];
1273        let registry = registry_at_slot_zero();
1274
1275        let first = legs();
1276        let second = heartbeat();
1277
1278        for _ in 0..5 {
1279            deliver(&registry, first, destination);
1280            deliver(&registry, second, destination);
1281            assert!(flush(&registry).is_empty());
1282        }
1283
1284        // Every relay goes quiet together: that is the counterparty, not a relay failure.
1285        for _ in 0..(3 * SILENT_FLUSHES_BEFORE_DEGRADED) {
1286            mint_only(&registry, first, destination);
1287            mint_only(&registry, second, destination);
1288            assert!(
1289                flush(&registry).is_empty(),
1290                "silence shared by every relayer names none of them"
1291            );
1292        }
1293
1294        let _ = (graph, me);
1295    }
1296
1297    /// A peer that simply stops talking must not be mistaken for a dead relayer.
1298    ///
1299    /// This is the case that killed every absolute gate: after a busy stretch the counters look
1300    /// exactly like a failing path -- SURBs still minted by keep-alives, nothing coming back. A
1301    /// delivered-ever bool, a recency decay, a volume threshold and a ratio collapse all fire here.
1302    /// Corroboration cannot, because when the peer goes quiet it goes quiet on *every* pair, so
1303    /// there is never a sibling to corroborate against.
1304    #[test]
1305    fn a_peer_that_goes_quiet_should_not_be_mistaken_for_a_dead_return_path() {
1306        let (graph, me, peers) = graph_with(2);
1307        let destination = peers[0];
1308        let registry = registry_at_slot_zero();
1309
1310        let first = legs();
1311        let second = heartbeat();
1312
1313        // A busy stretch: both pairs carrying real return traffic.
1314        for _ in 0..10 {
1315            deliver(&registry, first, destination);
1316            deliver(&registry, second, destination);
1317            assert!(flush(&registry).is_empty());
1318        }
1319
1320        // The application goes idle. Keep-alives keep minting on both pairs; the peer has nothing
1321        // to say on either.
1322        for _ in 0..(4 * SILENT_FLUSHES_BEFORE_DEGRADED) {
1323            mint_only(&registry, first, destination);
1324            mint_only(&registry, second, destination);
1325            assert!(
1326                flush(&registry).is_empty(),
1327                "a quiet peer is not a dead return path, however long it stays quiet"
1328            );
1329        }
1330
1331        let _ = (graph, me);
1332    }
1333
1334    #[test]
1335    fn a_pair_that_never_delivered_should_never_be_called_degraded() {
1336        let (graph, me, peers) = graph_with(1);
1337        let destination = peers[0];
1338        let registry = registry_at_slot_zero();
1339        let paths = legs();
1340
1341        // Measured regression: silence alone fired six times during healthy operation, once per
1342        // flush, on pairs whose replies had simply not landed yet. Minting hard and returning
1343        // nothing *yet* is what a young pair looks like, not what a dead one looks like.
1344        for _ in 0..SILENT_FLUSHES_BEFORE_DEGRADED + 3 {
1345            registry.record_expected(paths, MIN_EXPECTED_FOR_SILENCE * 10, destination);
1346            assert!(
1347                flush(&registry).is_empty(),
1348                "a pair that has never returned a reply is too young to call dead"
1349            );
1350        }
1351        let _ = (graph, me);
1352    }
1353
1354    #[test]
1355    fn a_single_reply_should_clear_the_silence() {
1356        let (graph, me, peers) = graph_with(1);
1357        let destination = peers[0];
1358        let registry = registry_at_slot_zero();
1359        let paths = legs();
1360
1361        deliver(&registry, paths, destination);
1362        assert!(flush(&registry).is_empty());
1363
1364        for _ in 1..SILENT_FLUSHES_BEFORE_DEGRADED {
1365            mint_only(&registry, paths, destination);
1366            assert!(flush(&registry).is_empty());
1367        }
1368
1369        // A path that delivers anything at all is not the failure this looks for, and the count
1370        // starts over rather than resuming where it left off.
1371        deliver(&registry, paths, destination);
1372        assert!(flush(&registry).is_empty());
1373        for _ in 1..SILENT_FLUSHES_BEFORE_DEGRADED {
1374            mint_only(&registry, paths, destination);
1375            assert!(flush(&registry).is_empty());
1376        }
1377        let _ = (graph, me);
1378    }
1379
1380    #[test]
1381    fn a_degraded_pair_should_not_fire_again_on_the_next_flush() {
1382        let (graph, me, peers) = graph_with(1);
1383        let destination = peers[0];
1384        let registry = registry_at_slot_zero();
1385        let paths = legs();
1386
1387        deliver(&registry, paths, destination);
1388        flush(&registry);
1389        for _ in 1..SILENT_FLUSHES_BEFORE_DEGRADED {
1390            mint_only(&registry, paths, destination);
1391            deliver(&registry, heartbeat(), destination);
1392            flush(&registry);
1393        }
1394        mint_only(&registry, paths, destination);
1395        deliver(&registry, heartbeat(), destination);
1396        assert_eq!(vec![destination], flush(&registry));
1397
1398        // Re-planning the same destination every second churns its cached candidates instead of
1399        // letting the new selection settle, so the gate re-arms from zero.
1400        for _ in 1..SILENT_FLUSHES_BEFORE_DEGRADED {
1401            mint_only(&registry, paths, destination);
1402            deliver(&registry, heartbeat(), destination);
1403            assert!(flush(&registry).is_empty());
1404        }
1405        let _ = (graph, me);
1406    }
1407
1408    #[test]
1409    fn an_idle_path_should_never_be_called_degraded() {
1410        let (graph, me, peers) = graph_with(1);
1411        let destination = peers[0];
1412        let registry = registry_at_slot_zero();
1413        let paths = legs();
1414
1415        deliver(&registry, paths, destination);
1416        assert!(flush(&registry).is_empty());
1417
1418        // Below the evidence floor: a trickle that happens not to have returned yet says nothing,
1419        // and treating it as failure would re-plan healthy paths during quiet periods.
1420        for _ in 0..SILENT_FLUSHES_BEFORE_DEGRADED + 2 {
1421            registry.record_expected(paths, MIN_EXPECTED_FOR_SILENCE - 1, destination);
1422            assert!(flush(&registry).is_empty());
1423        }
1424        let _ = (graph, me);
1425    }
1426
1427    #[test]
1428    fn a_reply_on_a_surb_we_never_minted_should_be_ignored() {
1429        let (graph, ..) = graph_with(1);
1430        let recorder = recorder(graph);
1431
1432        recorder.on_replied(&surb_id(9));
1433
1434        assert!(recorder.registry.drain().is_empty());
1435    }
1436}