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 latest: Option<(u32, proto::Stored)> = None;
147        for page in [page0, page0 + PAGE_SIZE] {
148            let mut address = page;
149            while address < page + PAGE_SIZE {
150                let mut bytes = [0u8; proto::SLOT_SIZE];
151                if flash.read_record(address, &mut bytes).is_ok() {
152                    latest = proto::consider_record(latest, address, &bytes);
153                }
154                address += proto::SLOT_SIZE as u32;
155            }
156        }
157        drop(flash);
158        // A tombstone is authoritative "nothing saved": older records
159        // still physically present are void.
160        let (slot, generation, payload) = match latest {
161            Some((slot, stored)) => {
162                let payload = match stored.record {
163                    proto::Record::Snapshot(payload) => Some(payload),
164                    proto::Record::Cleared => None,
165                };
166                (Some(slot), stored.generation, payload)
167            }
168            None => (None, 0, None),
169        };
170        debug_log(format_args!(
171            "proto-store mount page0=0x{page0:06x} slot={:?} generation={} payload={}",
172            slot,
173            generation,
174            payload.as_ref().map_or(0, |payload| payload.len()),
175        ));
176        (
177            Self {
178                flash: shared,
179                page0,
180                generation,
181                slot,
182                walked_back_to: Some(generation),
183            },
184            payload,
185        )
186    }
187
188    /// The shared flash this journal writes through.
189    ///
190    /// Exposed for whole-region operations that are not journal writes
191    /// at all — a factory reset erases every page in the NV region,
192    /// including journals no handle is currently mounted on.
193    pub fn flash(&self) -> &'static SharedFlash<M, F> {
194        self.flash
195    }
196
197    /// Copy the newest committed snapshot record strictly older than the
198    /// last one handed out into `out`, for the boot path's walk-back past
199    /// a payload the session rejected.
200    ///
201    /// Re-scans rather than retaining a runner-up at mount, so the mount
202    /// path never buffers a second copy; the scan is only ever paid on a
203    /// boot that already failed to restore. A tombstone ends the walk: it
204    /// asserts "nothing saved", and older records physically behind it
205    /// are void.
206    pub async fn older_snapshot(&mut self, out: &mut [u8]) -> Option<usize> {
207        let newer_than = self.walked_back_to?;
208        let mut flash = self.flash.lock().await;
209        let mut latest: Option<(u32, proto::Stored)> = None;
210        for page in [self.page0, self.page0 + PAGE_SIZE] {
211            let mut address = page;
212            while address < page + PAGE_SIZE {
213                let mut bytes = [0u8; proto::SLOT_SIZE];
214                if flash.read_record(address, &mut bytes).is_ok() {
215                    latest = proto::consider_older_record(latest, address, &bytes, newer_than);
216                }
217                address += proto::SLOT_SIZE as u32;
218            }
219        }
220        drop(flash);
221        let (_, stored) = latest?;
222        self.walked_back_to = Some(stored.generation);
223        let proto::Record::Snapshot(payload) = stored.record else {
224            debug_log(format_args!("proto-store walk-back hit=tombstone"));
225            self.walked_back_to = None;
226            return None;
227        };
228        debug_log(format_args!(
229            "proto-store walk-back generation={} payload={}",
230            stored.generation,
231            payload.len(),
232        ));
233        let len = payload.len().min(out.len());
234        out[..len].copy_from_slice(&payload[..len]);
235        (len == payload.len()).then_some(len)
236    }
237
238    pub async fn persist(&mut self, payload: &[u8]) -> Result<(), ()> {
239        if payload.len() > proto::MAX_PAYLOAD {
240            return Err(());
241        }
242        self.write(proto::RecordRef::Snapshot(payload)).await
243    }
244
245    /// The clear transaction is one committed tombstone record: if its
246    /// write fails or is interrupted, the previous snapshot remains
247    /// authoritative and the caller reports failure. Pages are never
248    /// erased as part of a clear — stale records are reclaimed by the
249    /// ordinary rotation.
250    pub async fn clear(&mut self) -> Result<(), ()> {
251        self.write(proto::RecordRef::Cleared).await
252    }
253
254    // The record travels by reference down to the single slot-image
255    // encode: this future is held across awaits in several task pools,
256    // and every avoided MAX_PAYLOAD copy is RAM off each of them.
257    async fn write(&mut self, record: proto::RecordRef<'_>) -> Result<(), ()> {
258        let generation = self.generation.wrapping_add(1);
259        let mut flash = self.flash.lock().await;
260        let target =
261            journal_write_target(&mut *flash, self.slot, self.page0, proto::SLOT_SIZE).await?;
262        match proto::write_record(&mut *flash, target, generation, record).await {
263            Ok(()) => {
264                debug_log(format_args!(
265                    "proto-store commit generation={generation} slot=0x{target:06x} cleared={}",
266                    matches!(record, proto::RecordRef::Cleared),
267                ));
268                self.generation = generation;
269                self.slot = Some(target);
270                Ok(())
271            }
272            Err(_) => {
273                debug_log(format_args!(
274                    "proto-store write=FAILED target=0x{target:06x}"
275                ));
276                Err(())
277            }
278        }
279    }
280}