Skip to main content

dmx_interface/
panic_report.rs

1//! Panic capture and last-panic persistence.
2//!
3//! Flow on a panic (or an Xtensa exception, which `esp-hal` turns into a
4//! `panic!`):
5//!
6//! 1. The banner (message, location, backtrace) and the most recent log
7//!    lines are moved out of the ring into
8//!    `.rtc_slow.persistent` — RTC slow memory, which esp-hal only zeroes
9//!    on power-on and preserves across software resets.
10//! 2. The same bytes are written to NVS (best effort, skipped when the
11//!    flash is busy or storage is down — the RTC copy is the fallback).
12//! 3. The boot-loop counter increments; after [`MAX_PANICS`] panics without
13//!    a 30 s stable run (see `mark_stable`) the firmware halts instead of
14//!    resetting. Otherwise it reboots.
15//!
16//! On the next boot [`report_last_panic`] replays the stored bytes into the
17//! log ring, so `inv monitor` shows the previous crash as soon as a host
18//! connects. Live output during the panic itself is impossible: the USB
19//! transfer pipeline is owned by the embassy executor, which a panic
20//! handler cannot drive.
21//!
22//! The report stays in NVS/RTC until the next panic overwrites it — the web
23//! UI can later expose it as an endpoint.
24
25use core::cell::{Cell, UnsafeCell};
26use core::panic::PanicInfo;
27
28use esp_backtrace::Backtrace;
29
30use crate::{logging, storage};
31
32/// Magic marking a valid RTC snapshot.
33///
34/// Deterministic: esp-hal zeroes `.rtc_slow.persistent` on power-on, so a
35/// garbage match cannot happen — after a power cycle this is `0`.
36const RTC_MAGIC: u32 = 0x4358_5050;
37
38/// Panic payload staged in RTC slow memory (12-byte header + data; RTC slow
39/// has 8 KiB in total).
40const SNAPSHOT_LEN: usize = 3072;
41
42/// Consecutive panics without a stable run after which the firmware halts
43/// instead of resetting (boot-loop protection).
44const MAX_PANICS: usize = 3;
45
46/// Guards against a panic while panicking (would otherwise recurse).
47///
48/// A `critical_section`-guarded cell instead of an `AtomicBool`: xtensa has
49/// no compare-and-swap, so `AtomicBool::swap` does not exist on this target.
50static IN_PANIC: critical_section::Mutex<Cell<bool>> =
51    critical_section::Mutex::new(Cell::new(false));
52
53/// Snapshot layout in `.rtc_slow.persistent`.
54#[repr(C)]
55struct RtcSnapshot {
56    magic: u32,
57    len: usize,
58    panic_count: usize,
59    data: [u8; SNAPSHOT_LEN],
60}
61
62/// Wrapper making [`RtcSnapshot`] a `'static` interior-mutable cell (avoids
63/// `static mut`, which edition 2024 rejects references to).
64struct RtcCell(UnsafeCell<RtcSnapshot>);
65
66// SAFETY: the cell is only touched from the panic handler and from boot-time
67// code; both run on the same core and never concurrently (panic handler
68// re-entry is blocked by `IN_PANIC`).
69unsafe impl Sync for RtcCell {}
70
71/// The persistent snapshot. esp-hal zeroes this section on power-on only,
72/// so the contents survive the software reset issued below.
73#[unsafe(link_section = ".rtc_slow.persistent")]
74static RTC: RtcCell = RtcCell(UnsafeCell::new(RtcSnapshot {
75    magic: 0,
76    len: 0,
77    panic_count: 0,
78    data: [0; SNAPSHOT_LEN],
79}));
80
81/// The `#[panic_handler]` of this firmware (esp-backtrace's built-in
82/// handler is disabled in `Cargo.toml` so the report can be captured here).
83#[panic_handler]
84fn panic_handler(info: &PanicInfo<'_>) -> ! {
85    let already_in_panic = critical_section::with(|cs| {
86        let flag = IN_PANIC.borrow(cs);
87        let prev = flag.get();
88        flag.set(true);
89        prev
90    });
91    if already_in_panic {
92        // Panic inside panic handling: do not touch memory again.
93        halt();
94    }
95
96    logging::push_line('!', format_args!("PANIC: {info}"));
97    if let Some(location) = info.location() {
98        logging::push_line('!', format_args!("at {location}"));
99    }
100    logging::push_line('!', format_args!("backtrace:"));
101    let backtrace = Backtrace::capture();
102    for frame in backtrace.frames() {
103        logging::push_line('!', format_args!("  0x{:08x}", frame.program_counter()));
104    }
105
106    let panic_count = {
107        // SAFETY: re-entry is blocked by `IN_PANIC` and no other code runs
108        // on this core while the handler is active.
109        let snapshot = unsafe { &mut *RTC.0.get() };
110        let previous = if snapshot.magic == RTC_MAGIC {
111            snapshot.panic_count
112        } else {
113            0
114        };
115        snapshot.magic = RTC_MAGIC;
116        snapshot.len = logging::drain_snapshot(&mut snapshot.data);
117        let count = previous.wrapping_add(1);
118        snapshot.panic_count = count;
119
120        // Durable copy; skipped when the flash is busy or storage is down
121        // (the RTC copy above is then the only record).
122        storage::persist_panic(snapshot.data.get(..snapshot.len).unwrap_or(&[]));
123        count
124    };
125
126    if panic_count >= MAX_PANICS {
127        halt();
128    }
129    esp_hal::system::software_reset();
130}
131
132/// Spin forever with interrupts masked.
133///
134/// Used for boot-loop protection and for a panic during panic handling.
135fn halt() -> ! {
136    critical_section::with(|_| -> ! {
137        loop {
138            core::hint::spin_loop();
139        }
140    })
141}
142
143/// Clear the boot-loop counter; called once the firmware ran stably for
144/// 30 s (see the `stable_marker` task in `main`).
145pub fn mark_stable() {
146    // SAFETY: single-core boot-time access; a panic cannot interleave with
147    // this on another core and re-entry is guarded.
148    let snapshot = unsafe { &mut *RTC.0.get() };
149    snapshot.panic_count = 0;
150}
151
152/// Replay the persisted last-panic report into the log ring.
153///
154/// Prefers the RTC snapshot: it is written on every panic (it cannot
155/// fail) and only zeroed on power-on, so whenever its magic checks out it
156/// is at least as fresh as the NVS copy — which is skipped while flash is
157/// busy, e.g. a panic during a config save. NVS is the fallback after a
158/// power cycle. Called once at startup, before the USB task is spawned;
159/// the ring buffers the report until a host connects. Nothing is cleared —
160/// the next panic overwrites it.
161pub fn report_last_panic() {
162    // SAFETY: boot path on a single core; the panic handler has not
163    // run yet this boot.
164    let snapshot = unsafe { &*RTC.0.get() };
165    let data = if snapshot.magic == RTC_MAGIC && snapshot.len > 0 && snapshot.len <= SNAPSHOT_LEN {
166        log::debug!("RTC panic snapshot ({} bytes)", snapshot.len);
167        snapshot.data.get(..snapshot.len).map(<[u8]>::to_vec)
168    } else {
169        storage::load_panic()
170    };
171    if let Some(data) = data {
172        log::error!("=== LAST PANIC (previous run) ===");
173        // Verbatim: the stored lines already carry their timestamps and
174        // markers, and re-splitting them would lose the line breaks.
175        logging::push_raw(&data);
176    }
177    // Snapshots taken later this boot must not contain the replayed bytes —
178    // otherwise each stored report would swallow the previous one.
179    logging::mark_snapshot_floor();
180}