umsh_ulcp_runtime/
journal.rs

1//! Two-page rotating journal handles, generic over the board's flash.
2//!
3//! `umsh-journal-store` owns the record *formats* and the committed-write
4//! engine; this module owns the runtime handle wrapped around them — the
5//! mount scan, the write-target rotation, and the boot path's walk-back.
6//! Both were previously duplicated in each firmware, identical except for
7//! which flash driver they closed over.
8//!
9//! The only genuinely per-board fact is that flash type: the nRF images
10//! drive MPSL-coordinated NVMC, the ESP32 image its SPI flash part. It
11//! enters as the `F` parameter, bounded by the `umsh-journal-store`
12//! traits the board already implements, and a board's fault-injection
13//! hooks stay in its own trait impls where they belong.
14
15use embassy_sync::blocking_mutex::raw::RawMutex;
16use embassy_sync::mutex::Mutex;
17
18use umsh_journal_store::proto;
19use umsh_journal_store::record::{
20    PAGE_SIZE, PageEraser, RecordReader, RecordWriter, erase_journal_page,
21};
22
23use crate::log::debug_log;
24
25/// Everything a journal needs from a board's flash. Blanket-implemented,
26/// so a board implements the three `umsh-journal-store` traits and gets
27/// this for free.
28pub trait JournalFlash:
29    RecordWriter<Error = ()> + PageEraser<Error = ()> + RecordReader<Error = ()>
30{
31}
32
33impl<F> JournalFlash for F where
34    F: RecordWriter<Error = ()> + PageEraser<Error = ()> + RecordReader<Error = ()>
35{
36}
37
38/// One flash driver shared between every journal on the board, behind
39/// the async mutex that serializes access to it.
40pub type SharedFlash<M, F> = Mutex<M, F>;
41
42/// The stored protocol payload as read at boot.
43pub type BootPayload = heapless::Vec<u8, { proto::MAX_PAYLOAD }>;
44
45/// Scan a two-page journal's slot range for a fully erased slot.
46fn erased_journal_slot<F: JournalFlash>(
47    flash: &mut F,
48    start: u32,
49    end: u32,
50    slot_size: usize,
51) -> Option<u32> {
52    let mut address = start;
53    while address < end {
54        let mut erased = true;
55        let mut offset = 0usize;
56        while offset < slot_size {
57            let mut chunk = [0u8; 256];
58            let take = (slot_size - offset).min(chunk.len());
59            match flash.read_record(address + offset as u32, &mut chunk[..take]) {
60                Ok(()) if chunk[..take].iter().all(|byte| *byte == 0xff) => {}
61                Ok(()) => {
62                    erased = false;
63                    break;
64                }
65                Err(_) => {
66                    debug_log(format_args!(
67                        "store erased-slot read=FAILED address=0x{address:06x}"
68                    ));
69                    erased = false;
70                    break;
71                }
72            }
73            offset += take;
74        }
75        if erased {
76            return Some(address);
77        }
78        address += slot_size as u32;
79    }
80    None
81}
82
83/// Pick the write target for a two-page rotating journal starting at
84/// `page0`: the next erased slot after the current record, or the
85/// opposite page after erasing it.
86pub async fn journal_write_target<F: JournalFlash>(
87    flash: &mut F,
88    current: Option<u32>,
89    page0: u32,
90    slot_size: usize,
91) -> Result<u32, ()> {
92    let page1 = page0 + PAGE_SIZE;
93    let target = if let Some(current) = current {
94        let page = if current < page1 { page0 } else { page1 };
95        erased_journal_slot(
96            flash,
97            current + slot_size as u32,
98            page + PAGE_SIZE,
99            slot_size,
100        )
101    } else {
102        erased_journal_slot(flash, page0, page0 + PAGE_SIZE, slot_size)
103    };
104    match target {
105        Some(target) => Ok(target),
106        None => {
107            let page = if current.is_some_and(|slot| slot < page1) {
108                page1
109            } else {
110                page0
111            };
112            debug_log(format_args!("store erase begin page=0x{page:06x}"));
113            if erase_journal_page(flash, page).await.is_err() {
114                debug_log(format_args!("store erase=FAILED page=0x{page:06x}"));
115                return Err(());
116            }
117            debug_log(format_args!("store erase=ok page=0x{page:06x}"));
118            Ok(page)
119        }
120    }
121}
122
123/// Runtime handle for one full-protocol record journal: the snapshot
124/// journal, the device-identity journal, the device-node counter
125/// journal, or a board's own preferences journal — selected by its first
126/// page. Executes durable effects; a caller's RAM mirror is only updated
127/// after these return.
128pub struct ProtoStore<M: RawMutex + 'static, F: JournalFlash + 'static> {
129    flash: &'static SharedFlash<M, F>,
130    /// First page of this journal's two-page rotation.
131    page0: u32,
132    generation: u32,
133    slot: Option<u32>,
134    /// Oldest generation already handed to the boot restore. Only the
135    /// snapshot-journal handle uses it, and only while the boot path is
136    /// walking back past a rejected payload.
137    walked_back_to: Option<u32>,
138}
139
140impl<M: RawMutex + 'static, F: JournalFlash + 'static> ProtoStore<M, F> {
141    pub async fn mount(
142        shared: &'static SharedFlash<M, F>,
143        page0: u32,
144    ) -> (Self, Option<BootPayload>) {
145        let mut flash = shared.lock().await;
146        let mut newest: Option<(u32, u32)> = None;
147        let mut bytes = [0u8; proto::SLOT_SIZE];
148        for page in [page0, page0 + PAGE_SIZE] {
149            let mut address = page;
150            while address < page + PAGE_SIZE {
151                if flash.read_record(address, &mut bytes).is_ok() {
152                    proto::consider_slot(&mut newest, address, &bytes);
153                }
154                address += proto::SLOT_SIZE as u32;
155            }
156        }
157        // Read the winner once, after the scan, and copy its payload
158        // straight into what this returns. The scan itself only tracks
159        // addresses and generations, so a mount's working set is one slot
160        // buffer no matter how many records are live — and no full record
161        // is ever materialized on the way out.
162        //
163        // A tombstone is authoritative "nothing saved": older records
164        // still physically present are void.
165        let mut slot = None;
166        let mut generation = 0;
167        let mut payload: Option<BootPayload> = None;
168        if let Some((address, _)) = newest
169            && flash.read_record(address, &mut bytes).is_ok()
170            && let Some((found, bytes)) = proto::payload_bytes(&bytes)
171        {
172            slot = Some(address);
173            generation = found;
174            payload = bytes.and_then(|bytes| BootPayload::from_slice(bytes).ok());
175        }
176        drop(flash);
177        debug_log(format_args!(
178            "proto-store mount page0=0x{page0:06x} slot={:?} generation={} payload={}",
179            slot,
180            generation,
181            payload.as_ref().map_or(0, |payload| payload.len()),
182        ));
183        (
184            Self {
185                flash: shared,
186                page0,
187                generation,
188                slot,
189                walked_back_to: Some(generation),
190            },
191            payload,
192        )
193    }
194
195    /// The shared flash this journal writes through.
196    ///
197    /// Exposed for whole-region operations that are not journal writes
198    /// at all — a factory reset erases every page in the NV region,
199    /// including journals no handle is currently mounted on.
200    pub fn flash(&self) -> &'static SharedFlash<M, F> {
201        self.flash
202    }
203
204    /// Copy the newest committed snapshot record strictly older than the
205    /// last one handed out into `out`, for the boot path's walk-back past
206    /// a payload the session rejected.
207    ///
208    /// Re-scans rather than retaining a runner-up at mount, so the mount
209    /// path never buffers a second copy; the scan is only ever paid on a
210    /// boot that already failed to restore. A tombstone ends the walk: it
211    /// asserts "nothing saved", and older records physically behind it
212    /// are void.
213    pub async fn older_snapshot(&mut self, out: &mut [u8]) -> Option<usize> {
214        let newer_than = self.walked_back_to?;
215        let mut flash = self.flash.lock().await;
216        let mut newest: Option<(u32, u32)> = None;
217        let mut bytes = [0u8; proto::SLOT_SIZE];
218        for page in [self.page0, self.page0 + PAGE_SIZE] {
219            let mut address = page;
220            while address < page + PAGE_SIZE {
221                if flash.read_record(address, &mut bytes).is_ok() {
222                    proto::consider_older_slot(&mut newest, address, &bytes, newer_than);
223                }
224                address += proto::SLOT_SIZE as u32;
225            }
226        }
227        let (address, _) = newest?;
228        if flash.read_record(address, &mut bytes).is_err() {
229            return None;
230        }
231        drop(flash);
232        let (generation, payload) = proto::payload_bytes(&bytes)?;
233        self.walked_back_to = Some(generation);
234        let Some(payload) = payload else {
235            debug_log(format_args!("proto-store walk-back hit=tombstone"));
236            self.walked_back_to = None;
237            return None;
238        };
239        debug_log(format_args!(
240            "proto-store walk-back generation={generation} payload={}",
241            payload.len(),
242        ));
243        let len = payload.len().min(out.len());
244        out[..len].copy_from_slice(&payload[..len]);
245        (len == payload.len()).then_some(len)
246    }
247
248    pub async fn persist(&mut self, payload: &[u8]) -> Result<(), ()> {
249        if payload.len() > proto::MAX_PAYLOAD {
250            return Err(());
251        }
252        self.write(proto::RecordRef::Snapshot(payload)).await
253    }
254
255    /// The clear transaction is one committed tombstone record: if its
256    /// write fails or is interrupted, the previous snapshot remains
257    /// authoritative and the caller reports failure. Pages are never
258    /// erased as part of a clear — stale records are reclaimed by the
259    /// ordinary rotation.
260    pub async fn clear(&mut self) -> Result<(), ()> {
261        self.write(proto::RecordRef::Cleared).await
262    }
263
264    // The record travels by reference down to the single slot-image
265    // encode: this future is held across awaits in several task pools,
266    // and every avoided MAX_PAYLOAD copy is RAM off each of them.
267    async fn write(&mut self, record: proto::RecordRef<'_>) -> Result<(), ()> {
268        let generation = self.generation.wrapping_add(1);
269        let mut flash = self.flash.lock().await;
270        let target =
271            journal_write_target(&mut *flash, self.slot, self.page0, proto::SLOT_SIZE).await?;
272        match proto::write_record(&mut *flash, target, generation, record).await {
273            Ok(()) => {
274                debug_log(format_args!(
275                    "proto-store commit generation={generation} slot=0x{target:06x} cleared={}",
276                    matches!(record, proto::RecordRef::Cleared),
277                ));
278                self.generation = generation;
279                self.slot = Some(target);
280                Ok(())
281            }
282            Err(_) => {
283                debug_log(format_args!(
284                    "proto-store write=FAILED target=0x{target:06x}"
285                ));
286                Err(())
287            }
288        }
289    }
290}