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}