Skip to main content

g2g_core/
supervise.rs

1//! Runtime fault recovery for the static heap-free MCU path: a supervisor that
2//! wraps a `source -> sink` chain and turns a returned fault
3//! ([`G2gError`](crate::error::G2gError)) into a *bounded, deterministic*
4//! recovery action instead of aborting the pipeline.
5//!
6//! The static runners ([`run_source_sink`](crate::staticelem::run_source_sink),
7//! [`step_source_sink`](crate::staticelem::step_source_sink)) propagate a fault
8//! straight out: the first `PoolExhausted` / `Hardware(Peripheral)` / overrun
9//! ends the pipeline. That is correct for a host tool, but the MCU / safety
10//! market needs the opposite default: a transient peripheral glitch should be
11//! retried or degraded around, a persistent one should re-initialize the stages,
12//! and only an unrecoverable fault should stop, at which point an unpetted
13//! hardware watchdog resets the chip. This module supplies that policy layer, in
14//! the no-alloc subset, so it links on a target with no allocator.
15//!
16//! The pieces:
17//! - [`FaultPolicy`] classifies each fault into a [`Recovery`] action. The
18//!   supplied [`RetryThenReset`] and [`SkipBounded`] cover the two canonical
19//!   safety patterns (recover-in-place vs. degrade-and-continue); both are
20//!   bounded, so a persistent fault always escalates in finite steps.
21//! - [`Recover`] is the per-stage re-initialization seam (re-arm DMA, flush a
22//!   partial packet, reset a decoder). Its default is a no-op, so a stateless
23//!   stage opts in with `impl Recover for Foo {}`.
24//! - [`Watchdog`] is petted on every frame of real forward progress; when the
25//!   supervisor stops (a wedged or escalated pipeline) the watchdog is no longer
26//!   fed, so a hardware watchdog fires and resets the MCU.
27//! - [`SupervisorReport`] is the fault accounting (frames / faults / retries /
28//!   resets / skips / escalation) the safety case wants for traceability.
29//!
30//! Everything is bounded by construction: [`step_supervised`] resolves each frame
31//! in at most [`MAX_ATTEMPTS`] internal iterations regardless of the policy, so a
32//! buggy policy can never spin the supervisor forever.
33
34use crate::error::G2gError;
35use crate::staticelem::{drive_ready, Chain, SinkChain, SourceChain, StaticSink, StaticSource};
36
37/// What the supervisor does about a fault, decided by a [`FaultPolicy`].
38///
39/// The four actions are the deterministic MCU fault-handling vocabulary:
40/// try again, drop and keep the cadence, re-initialize, or give up.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Recovery {
43    /// Re-drive the step immediately (a transient fault worth another attempt:
44    /// a momentarily exhausted pool, a one-off capture timeout). Because the
45    /// step pulls the *next* frame afresh, this suits source-side and transient
46    /// faults; the frame that faulted is not buffered for replay (that would
47    /// break the zero-copy single-frame-in-flight model).
48    Retry,
49    /// Drop this frame and return control (degraded mode): the stream keeps
50    /// flowing at cadence past a corrupt or late frame. The consecutive-fault
51    /// count is *not* cleared, so a source that only ever skips still escalates
52    /// in finite steps and never pets the watchdog into thinking it is healthy.
53    Skip,
54    /// Re-initialize both stages via their [`Recover`] hook, then return control.
55    /// For a fault a fresh peripheral state clears (re-arm the DMA, reset the
56    /// codec). A failing recover escalates.
57    Reset,
58    /// Give up: the fault is structural or the retry/reset budget is spent. The
59    /// supervisor returns it and does not pet the watchdog, so on hardware the
60    /// watchdog fires; a caller can also trigger a system reset explicitly.
61    Escalate,
62}
63
64/// Classifies a fault into a [`Recovery`] action, given the fault and how many
65/// consecutive faults have occurred since the last frame of real progress. An
66/// implementation must be *bounded*: for a persistently faulting stage it must
67/// eventually return [`Recovery::Escalate`], so the supervisor cannot livelock.
68pub trait FaultPolicy {
69    /// Decide what to do about `err`, the `consecutive`-th fault in a row
70    /// (1 on the first fault after a good frame, climbing until one succeeds).
71    fn classify(&mut self, err: &G2gError, consecutive: u32) -> Recovery;
72}
73
74/// Bounded recover-in-place policy: retry a fault up to `max_retries` times, then
75/// re-initialize the stages up to `max_resets` times, then escalate. Structural
76/// faults (a caps / configuration / copy-budget violation) never retry, because
77/// re-running the same negotiation cannot succeed; they escalate at once. This is
78/// the default for a capture pipeline, where re-arming the peripheral is the
79/// natural response to a bus glitch.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct RetryThenReset {
82    /// Consecutive faults answered with [`Recovery::Retry`] before escalating to
83    /// a reset.
84    pub max_retries: u32,
85    /// Consecutive faults (after the retry budget) answered with
86    /// [`Recovery::Reset`] before giving up.
87    pub max_resets: u32,
88}
89
90impl RetryThenReset {
91    /// A policy that retries `max_retries` times, then resets `max_resets` times,
92    /// then escalates.
93    pub const fn new(max_retries: u32, max_resets: u32) -> Self {
94        Self {
95            max_retries,
96            max_resets,
97        }
98    }
99}
100
101impl Default for RetryThenReset {
102    /// Two retries, then one reset, then escalate: a transient glitch is retried,
103    /// a sticky one triggers a single re-init, a persistent one stops.
104    fn default() -> Self {
105        Self::new(2, 1)
106    }
107}
108
109/// A fault the [`RetryThenReset`] ladder treats as structural (never transient):
110/// re-running the exact same operation cannot clear it, so it escalates at once.
111fn is_structural(err: &G2gError) -> bool {
112    matches!(
113        err,
114        G2gError::CapsMismatch
115            | G2gError::NotConfigured
116            | G2gError::FixationFailed
117            | G2gError::UnsupportedDomain
118            | G2gError::AllocationConflict
119            | G2gError::CopyBudget
120    )
121}
122
123impl FaultPolicy for RetryThenReset {
124    fn classify(&mut self, err: &G2gError, consecutive: u32) -> Recovery {
125        if is_structural(err) {
126            return Recovery::Escalate;
127        }
128        // saturating so a huge consecutive count cannot wrap the threshold and
129        // wrongly re-enter the retry band.
130        let reset_ceiling = self.max_retries.saturating_add(self.max_resets);
131        if consecutive <= self.max_retries {
132            Recovery::Retry
133        } else if consecutive <= reset_ceiling {
134            Recovery::Reset
135        } else {
136            Recovery::Escalate
137        }
138    }
139}
140
141/// Bounded degrade-and-continue policy: drop up to `max_skips` consecutive
142/// faulting frames (keeping the output cadence), then escalate. The default for a
143/// display / telemetry pipeline where a dropped frame is the safe degraded state
144/// and re-arming per glitch is not worth the stall. Structural faults escalate at
145/// once, as in [`RetryThenReset`].
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct SkipBounded {
148    /// Consecutive faulting frames dropped before escalating.
149    pub max_skips: u32,
150}
151
152impl SkipBounded {
153    /// A policy that skips up to `max_skips` consecutive faults, then escalates.
154    pub const fn new(max_skips: u32) -> Self {
155        Self { max_skips }
156    }
157}
158
159impl Default for SkipBounded {
160    fn default() -> Self {
161        Self::new(4)
162    }
163}
164
165impl FaultPolicy for SkipBounded {
166    fn classify(&mut self, err: &G2gError, consecutive: u32) -> Recovery {
167        if is_structural(err) || consecutive > self.max_skips {
168            Recovery::Escalate
169        } else {
170            Recovery::Skip
171        }
172    }
173}
174
175/// Per-stage re-initialization seam, called on a [`Recovery::Reset`]: bring the
176/// stage back to a known-good state so the next frame starts clean (re-arm the
177/// capture DMA, flush a half-written packet, reset a decoder's reference state).
178///
179/// The default is a no-op, so a stateless stage opts in with `impl Recover for
180/// Foo {}` and a stage with peripheral state overrides `recover`. A supervised
181/// pipeline therefore *declares* each stage's recovery behavior, which is the
182/// traceability a safety case needs. A `recover` that itself fails escalates the
183/// supervisor.
184///
185/// `#[allow(async_fn_in_trait)]` for the same reason as the rest of the static
186/// element model: a single-executor MCU path, no `Send` needed, no boxing.
187#[allow(async_fn_in_trait)]
188pub trait Recover {
189    /// Re-initialize after a fault. The default is a no-op (a stateless stage).
190    async fn recover(&mut self) -> Result<(), G2gError> {
191        Ok(())
192    }
193}
194
195/// A watchdog the supervisor pets on every frame of real forward progress. On an
196/// MCU this refreshes a hardware watchdog timer (STM32 IWDG, an RTOS software
197/// watchdog); when the supervisor stops petting, a wedged or escalated pipeline,
198/// the timer expires and resets the chip. This is the backstop for a pipeline
199/// that neither advances nor escalates (e.g. a stage that hangs an interrupt):
200/// petting only on `Advanced` means "no real frame for too long" trips it.
201pub trait Watchdog {
202    /// Refresh the watchdog; called once per delivered frame.
203    fn pet(&mut self);
204}
205
206/// A no-op watchdog for a pipeline that does not use one (host tests, a target
207/// whose reset is handled elsewhere).
208#[derive(Debug, Default, Clone, Copy)]
209pub struct NoWatchdog;
210
211impl Watchdog for NoWatchdog {
212    fn pet(&mut self) {}
213}
214
215// So a caller keeps its watchdog to inspect after the run (the runners take
216// ownership), a `&mut W` is itself a watchdog.
217impl<W: Watchdog> Watchdog for &mut W {
218    fn pet(&mut self) {
219        (**self).pet();
220    }
221}
222
223/// Running fault accounting across supervised steps: the evidence a safety case
224/// wants (how many faults occurred, how they were handled, whether the pipeline
225/// escalated). A caller keeps one across a whole run (or a C superloop keeps one
226/// across `step_supervised` calls) and inspects it at the end.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct SupervisorReport {
229    /// Frames of real forward progress delivered to the sink.
230    pub frames: u32,
231    /// Total faults observed (across all handling actions).
232    pub faults: u32,
233    /// Faults answered by re-driving the step.
234    pub retries: u32,
235    /// Faults answered by re-initializing the stages.
236    pub resets: u32,
237    /// Faults answered by dropping the frame (degraded mode).
238    pub skips: u32,
239    /// Faults in a row since the last delivered frame (the value the policy sees).
240    pub consecutive_faults: u32,
241    /// Whether the supervisor has escalated (given up on a fault).
242    pub escalated: bool,
243    /// The most recent fault observed, if any.
244    pub last_error: Option<G2gError>,
245}
246
247impl SupervisorReport {
248    /// A fresh report with all counters zero.
249    pub const fn new() -> Self {
250        Self {
251            frames: 0,
252            faults: 0,
253            retries: 0,
254            resets: 0,
255            skips: 0,
256            consecutive_faults: 0,
257            escalated: false,
258            last_error: None,
259        }
260    }
261}
262
263impl Default for SupervisorReport {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269/// The outcome of one [`step_supervised`] call.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub enum Supervised {
272    /// A frame flowed to the sink; the watchdog was petted.
273    Advanced,
274    /// A faulting frame was dropped (degraded mode); call again for the next.
275    Skipped,
276    /// The stages were re-initialized after a fault; call again.
277    Recovered,
278    /// The source reached end of stream; stop.
279    Eos,
280    /// A stage suspended (`Poll::Pending`). Like [`Step::Pending`], the supervisor
281    /// targets synchronous static stages; a genuinely suspending pipeline belongs
282    /// on a real executor. Reported, never silently looped.
283    ///
284    /// [`Step::Pending`]: crate::staticelem::Step
285    Pending,
286    /// The fault could not be recovered within the policy's bounds. The watchdog
287    /// was not petted; on hardware it now fires. The caller should stop (or
288    /// trigger a system reset).
289    Escalated(G2gError),
290}
291
292/// The hard upper bound on internal iterations [`step_supervised`] performs to
293/// resolve one frame, regardless of the [`FaultPolicy`]. A correct bounded policy
294/// escalates far below this; the cap is the belt-and-suspenders guarantee that a
295/// *buggy* policy (one that never escalates) still cannot hang the supervisor.
296pub const MAX_ATTEMPTS: u32 = 64;
297
298/// One attempt at pulling and delivering a frame: `Ok(true)` a frame flowed,
299/// `Ok(false)` end of stream, `Err` a fault. Named `async fn` so it monomorphizes
300/// like the [`step_source_sink`](crate::staticelem::step_source_sink) body.
301async fn attempt<S, K>(src: &mut S, sink: &mut K) -> Result<bool, G2gError>
302where
303    S: StaticSource,
304    K: StaticSink,
305{
306    match src.next().await? {
307        Some(frame) => {
308            sink.consume(frame).await?;
309            Ok(true)
310        }
311        None => Ok(false),
312    }
313}
314
315/// Re-initialize both stages (source first, then sink) for a [`Recovery::Reset`].
316async fn recover_both<S, K>(src: &mut S, sink: &mut K) -> Result<(), G2gError>
317where
318    S: Recover,
319    K: Recover,
320{
321    src.recover().await?;
322    sink.recover().await?;
323    Ok(())
324}
325
326/// Run one frame through a supervised `source -> sink` chain, applying `policy` to
327/// any fault and petting `watchdog` on success, accumulating into `report`. The
328/// caller owns the loop (a C superloop over the FFI seam, an RTOS task), so it can
329/// interleave other work and feed its own watchdog between frames; use
330/// [`run_supervised`] for the run-to-completion case.
331///
332/// Bounded: resolves in at most [`MAX_ATTEMPTS`] internal iterations whatever the
333/// policy does. Compose a transform tail into the sink with
334/// [`SinkChain`](crate::staticelem::SinkChain) (and a head with
335/// [`SourceChain`](crate::staticelem::SourceChain)) so this steps any linear
336/// graph; both combinators forward [`Recover`] to their parts.
337pub fn step_supervised<S, K, P, W>(
338    src: &mut S,
339    sink: &mut K,
340    policy: &mut P,
341    watchdog: &mut W,
342    report: &mut SupervisorReport,
343) -> Supervised
344where
345    S: StaticSource + Recover,
346    K: StaticSink + Recover,
347    P: FaultPolicy,
348    W: Watchdog,
349{
350    let mut iterations = 0;
351    while iterations < MAX_ATTEMPTS {
352        iterations += 1;
353        match drive_ready(attempt(src, sink)) {
354            Some(Ok(true)) => {
355                report.frames = report.frames.saturating_add(1);
356                report.consecutive_faults = 0;
357                watchdog.pet();
358                return Supervised::Advanced;
359            }
360            Some(Ok(false)) => return Supervised::Eos,
361            Some(Err(err)) => {
362                report.faults = report.faults.saturating_add(1);
363                report.consecutive_faults = report.consecutive_faults.saturating_add(1);
364                report.last_error = Some(err.clone());
365                match policy.classify(&err, report.consecutive_faults) {
366                    Recovery::Retry => {
367                        report.retries = report.retries.saturating_add(1);
368                        // loop: re-drive the step (bounded by MAX_ATTEMPTS).
369                    }
370                    Recovery::Skip => {
371                        report.skips = report.skips.saturating_add(1);
372                        return Supervised::Skipped;
373                    }
374                    Recovery::Reset => {
375                        report.resets = report.resets.saturating_add(1);
376                        match drive_ready(recover_both(src, sink)) {
377                            Some(Ok(())) => return Supervised::Recovered,
378                            Some(Err(rerr)) => {
379                                report.escalated = true;
380                                return Supervised::Escalated(rerr);
381                            }
382                            // recover_both suspended: a re-init that awaits belongs
383                            // on a real executor, escalate rather than spin.
384                            None => {
385                                report.escalated = true;
386                                return Supervised::Escalated(err);
387                            }
388                        }
389                    }
390                    Recovery::Escalate => {
391                        report.escalated = true;
392                        return Supervised::Escalated(err);
393                    }
394                }
395            }
396            None => return Supervised::Pending,
397        }
398    }
399    // Hard cap reached (a policy that never escalated): force a bounded stop.
400    report.escalated = true;
401    Supervised::Escalated(report.last_error.clone().unwrap_or(G2gError::Shutdown))
402}
403
404/// How a supervised run ended.
405#[derive(Debug, Clone, PartialEq, Eq)]
406pub enum RunOutcome {
407    /// The source reached end of stream; the pipeline completed.
408    Completed,
409    /// A fault could not be recovered within the policy's bounds; the pipeline
410    /// stopped. The watchdog was not petted since the last delivered frame, so on
411    /// hardware it now resets the chip.
412    Escalated(G2gError),
413    /// A stage suspended. The synchronous supervisor cannot drive it; run this
414    /// pipeline on a real executor instead.
415    Suspended,
416}
417
418/// Drive a supervised `source -> sink` chain to end of stream (or escalation),
419/// returning the fault accounting and the terminal outcome. The run-to-completion
420/// analog of [`step_supervised`]; a caller that must interleave other work uses
421/// the step form instead.
422pub fn run_supervised<S, K, P, W>(
423    mut src: S,
424    mut sink: K,
425    mut policy: P,
426    mut watchdog: W,
427) -> (SupervisorReport, RunOutcome)
428where
429    S: StaticSource + Recover,
430    K: StaticSink + Recover,
431    P: FaultPolicy,
432    W: Watchdog,
433{
434    let mut report = SupervisorReport::new();
435    loop {
436        match step_supervised(&mut src, &mut sink, &mut policy, &mut watchdog, &mut report) {
437            Supervised::Advanced | Supervised::Skipped | Supervised::Recovered => {}
438            Supervised::Eos => return (report, RunOutcome::Completed),
439            Supervised::Escalated(err) => return (report, RunOutcome::Escalated(err)),
440            Supervised::Pending => return (report, RunOutcome::Suspended),
441        }
442    }
443}
444
445// Recover forwarding for the static-chain combinators and `&mut`, so a supervised
446// sink built as `SinkChain(transform, sink)` (or a source as `SourceChain`)
447// recovers all of its parts. Each recurses; a bare stage supplies its own (the
448// default no-op unless it has peripheral state).
449
450impl<T: Recover> Recover for &mut T {
451    async fn recover(&mut self) -> Result<(), G2gError> {
452        (**self).recover().await
453    }
454}
455
456impl<A: Recover, B: Recover> Recover for Chain<A, B> {
457    async fn recover(&mut self) -> Result<(), G2gError> {
458        self.0.recover().await?;
459        self.1.recover().await
460    }
461}
462
463impl<S: Recover, T: Recover> Recover for SourceChain<S, T> {
464    async fn recover(&mut self) -> Result<(), G2gError> {
465        self.0.recover().await?;
466        self.1.recover().await
467    }
468}
469
470impl<T: Recover, K: Recover> Recover for SinkChain<T, K> {
471    async fn recover(&mut self) -> Result<(), G2gError> {
472        self.0.recover().await?;
473        self.1.recover().await
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::frame::{Frame, FrameTiming};
481    use crate::memory::{MemoryDomain, SystemSlice};
482
483    static BYTE: [u8; 1] = [7];
484
485    fn one_frame(seq: u64) -> Frame {
486        // SAFETY: BYTE is 'static and never mutated; the lent slice covers its one
487        // valid byte and needs no reclamation (free = None).
488        let slice =
489            unsafe { SystemSlice::from_foreign(BYTE.as_ptr(), 1, None, core::ptr::null_mut()) };
490        Frame::new(
491            MemoryDomain::System(slice),
492            FrameTiming {
493                pts_ns: seq,
494                ..FrameTiming::default()
495            },
496            seq,
497        )
498    }
499
500    /// A source that faults on a configurable set of (attempt-)indices, counting
501    /// how many times it was re-initialized. `fault_first_n_of_each` makes the
502    /// fault transient: the k-th distinct frame faults on its first attempt and
503    /// succeeds on the retry.
504    struct FaultSource {
505        emitted: u32,
506        limit: u32,
507        // faults still owed on the current frame before it will succeed.
508        fault_countdown: u32,
509        faults_per_frame: u32,
510        recovers: u32,
511        // once true, every attempt faults (the permanent-fault case).
512        permanent: bool,
513    }
514    impl FaultSource {
515        fn transient(limit: u32, faults_per_frame: u32) -> Self {
516            Self {
517                emitted: 0,
518                limit,
519                fault_countdown: faults_per_frame,
520                faults_per_frame,
521                recovers: 0,
522                permanent: false,
523            }
524        }
525        fn permanent() -> Self {
526            Self {
527                emitted: 0,
528                limit: 1,
529                fault_countdown: 1,
530                faults_per_frame: 1,
531                recovers: 0,
532                permanent: true,
533            }
534        }
535    }
536    impl StaticSource for FaultSource {
537        async fn next(&mut self) -> Result<Option<Frame>, G2gError> {
538            if !self.permanent && self.emitted >= self.limit {
539                return Ok(None);
540            }
541            if self.permanent {
542                return Err(G2gError::Hardware(crate::error::HardwareError::Peripheral));
543            }
544            if self.fault_countdown > 0 {
545                self.fault_countdown -= 1;
546                return Err(G2gError::PoolExhausted);
547            }
548            let seq = self.emitted as u64;
549            self.emitted += 1;
550            self.fault_countdown = self.faults_per_frame; // arm the next frame's faults
551            Ok(Some(one_frame(seq)))
552        }
553    }
554    impl Recover for FaultSource {
555        async fn recover(&mut self) -> Result<(), G2gError> {
556            self.recovers += 1;
557            Ok(())
558        }
559    }
560
561    struct CountSink {
562        n: u32,
563    }
564    impl StaticSink for CountSink {
565        async fn consume(&mut self, _frame: Frame) -> Result<(), G2gError> {
566            self.n += 1;
567            Ok(())
568        }
569    }
570    impl Recover for CountSink {}
571
572    struct CountWatchdog {
573        pets: u32,
574    }
575    impl Watchdog for CountWatchdog {
576        fn pet(&mut self) {
577            self.pets += 1;
578        }
579    }
580
581    #[test]
582    fn transient_faults_are_retried_and_every_frame_still_arrives() {
583        // Each of 5 frames faults once (PoolExhausted) then succeeds on retry.
584        let src = FaultSource::transient(5, 1);
585        let mut sink = CountSink { n: 0 };
586        let mut wd = CountWatchdog { pets: 0 };
587        let (report, outcome) = run_supervised(src, &mut sink, RetryThenReset::default(), &mut wd);
588        assert_eq!(outcome, RunOutcome::Completed, "recovered to EOS");
589        assert_eq!(
590            sink.n, 5,
591            "all frames delivered despite the transient faults"
592        );
593        assert_eq!(report.frames, 5);
594        assert_eq!(report.faults, 5, "one fault per frame");
595        assert_eq!(report.retries, 5, "each answered by a retry");
596        assert_eq!(report.resets, 0);
597        assert!(!report.escalated);
598        assert_eq!(wd.pets, 5, "watchdog petted once per delivered frame");
599    }
600
601    #[test]
602    fn sticky_fault_triggers_a_reset_then_recovers() {
603        // Two faults per frame: exceeds the 2-retry budget, so the 3rd consecutive
604        // fault triggers a reset; the frame then succeeds. faults_per_frame=2 means
605        // frame k faults twice then emits.
606        let src = FaultSource::transient(3, 2);
607        let mut sink = CountSink { n: 0 };
608        let (report, outcome) =
609            run_supervised(src, &mut sink, RetryThenReset::new(2, 1), NoWatchdog);
610        assert_eq!(outcome, RunOutcome::Completed);
611        assert_eq!(sink.n, 3, "all frames eventually delivered");
612        // Per frame: fault, fault -> both under the 2-retry budget (consecutive 1,2)
613        // so 2 retries, then the frame succeeds (consecutive resets to 0). No reset
614        // is reached because 2 faults <= max_retries.
615        assert_eq!(report.retries, 6, "two retries per frame, three frames");
616        assert_eq!(report.resets, 0, "two faults stays within the retry budget");
617        assert!(!report.escalated);
618    }
619
620    #[test]
621    fn exceeding_the_retry_budget_escalates_to_reset() {
622        // Three faults per frame with a 2-retry / 1-reset budget: consecutive
623        // 1,2 -> retry, 3 -> reset (clears the peripheral in this mock), 4th
624        // attempt succeeds.
625        let src = FaultSource::transient(2, 3);
626        let mut sink = CountSink { n: 0 };
627        let (report, outcome) =
628            run_supervised(src, &mut sink, RetryThenReset::new(2, 1), NoWatchdog);
629        assert_eq!(outcome, RunOutcome::Completed);
630        assert_eq!(sink.n, 2);
631        assert_eq!(report.resets, 2, "one reset per frame (2 frames)");
632        assert!(!report.escalated);
633    }
634
635    #[test]
636    fn permanent_fault_escalates_within_bounds_and_stops_petting() {
637        let src = FaultSource::permanent();
638        let mut sink = CountSink { n: 0 };
639        let mut wd = CountWatchdog { pets: 0 };
640        let (report, outcome) = run_supervised(src, &mut sink, RetryThenReset::new(2, 1), &mut wd);
641        match outcome {
642            RunOutcome::Escalated(G2gError::Hardware(_)) => {}
643            other => panic!("expected escalation on a permanent fault, got {other:?}"),
644        }
645        assert_eq!(sink.n, 0, "no frame ever delivered");
646        assert!(report.escalated);
647        // 2 retries + 1 reset then escalate on the 4th consecutive fault.
648        assert_eq!(report.faults, 4);
649        assert_eq!(report.retries, 2);
650        assert_eq!(report.resets, 1);
651        assert_eq!(wd.pets, 0, "watchdog never petted -> hardware reset fires");
652    }
653
654    #[test]
655    fn structural_fault_escalates_immediately_without_retrying() {
656        // A CapsMismatch is structural: no retry, straight to escalation.
657        struct CapsFault;
658        impl StaticSource for CapsFault {
659            async fn next(&mut self) -> Result<Option<Frame>, G2gError> {
660                Err(G2gError::CapsMismatch)
661            }
662        }
663        impl Recover for CapsFault {}
664        let mut sink = CountSink { n: 0 };
665        let (report, outcome) =
666            run_supervised(CapsFault, &mut sink, RetryThenReset::default(), NoWatchdog);
667        assert_eq!(outcome, RunOutcome::Escalated(G2gError::CapsMismatch));
668        assert_eq!(report.faults, 1, "escalated on the first fault, no retries");
669        assert_eq!(report.retries, 0);
670        assert_eq!(report.resets, 0);
671    }
672
673    #[test]
674    fn skip_policy_degrades_past_faults_and_keeps_the_good_frames() {
675        // Every other frame faults; SkipBounded drops the faulters and delivers the
676        // rest, staying at cadence, until EOS.
677        struct AlternatingFault {
678            emitted: u32,
679            attempts: u32,
680            limit: u32,
681        }
682        impl StaticSource for AlternatingFault {
683            async fn next(&mut self) -> Result<Option<Frame>, G2gError> {
684                if self.emitted >= self.limit {
685                    return Ok(None);
686                }
687                self.attempts += 1;
688                // Fault once on every 3rd attempt.
689                if self.attempts % 3 == 0 {
690                    return Err(G2gError::Hardware(crate::error::HardwareError::Peripheral));
691                }
692                let seq = self.emitted as u64;
693                self.emitted += 1;
694                Ok(Some(one_frame(seq)))
695            }
696        }
697        impl Recover for AlternatingFault {}
698        let src = AlternatingFault {
699            emitted: 0,
700            attempts: 0,
701            limit: 6,
702        };
703        let mut sink = CountSink { n: 0 };
704        let (report, outcome) = run_supervised(src, &mut sink, SkipBounded::new(2), NoWatchdog);
705        assert_eq!(
706            outcome,
707            RunOutcome::Completed,
708            "degraded past the faults to EOS"
709        );
710        assert_eq!(sink.n, 6, "every good frame delivered");
711        assert!(
712            report.skips > 0,
713            "faulting frames were skipped, not retried"
714        );
715        assert_eq!(report.retries, 0);
716        assert!(!report.escalated);
717    }
718
719    #[test]
720    fn a_never_escalating_policy_still_stops_at_the_hard_cap() {
721        // A pathological policy that always retries: the MAX_ATTEMPTS belt forces a
722        // bounded stop so the supervisor cannot hang.
723        struct AlwaysRetry;
724        impl FaultPolicy for AlwaysRetry {
725            fn classify(&mut self, _err: &G2gError, _consecutive: u32) -> Recovery {
726                Recovery::Retry
727            }
728        }
729        let src = FaultSource::permanent();
730        let mut sink = CountSink { n: 0 };
731        let mut report = SupervisorReport::new();
732        let out = step_supervised(
733            &mut { src },
734            &mut sink,
735            &mut AlwaysRetry,
736            &mut NoWatchdog,
737            &mut report,
738        );
739        assert!(
740            matches!(out, Supervised::Escalated(_)),
741            "hard cap forced a stop"
742        );
743        assert!(report.escalated);
744        assert_eq!(report.faults, MAX_ATTEMPTS, "stopped exactly at the cap");
745    }
746}