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}