Skip to main content

nros_log/
early.rs

1//! Records that could not be delivered when they were raised: before any sink
2//! was installed, or while another thread was inside a sink (issue 2000).
3//!
4//! ## The problem this exists for
5//!
6//! [`crate::init`] publishes the sink list. A record raised before that call
7//! has nowhere to go, and for most of this crate's life it was constructed,
8//! dispatched and DROPPED — silently, and invisibly to its author, who cannot
9//! know what the board did before reaching their code.
10//!
11//! Issue 0708 answered by requiring every board boot funnel to call
12//! `init_default()`, gated on funnels spelled `pub fn run*`. That is a SEARCH
13//! for boot paths and it kept losing: NuttX's funnel is `pub extern "C" fn
14//! nsh_main`, and three board crates did not link `nros-log` in the
15//! configuration holding the funnel at all.
16//!
17//! Issue 0710 answered by having dispatch install the platform sink itself.
18//! That removed the search — but it put `nros_platform_log_write` on a path
19//! EVERY binary executes, which turned a pluggable delivery into a LINK-TIME
20//! requirement. Seven test targets in `nros-rmw-cffi` and most of `nros-tests`
21//! stopped linking under `check-workspace-features`, and no Cargo feature can
22//! fix that: `nros-platform-cffi` and `nros-rmw-bridge` enable
23//! `nros-node/rmw-cffi` unconditionally, so feature unification turns any
24//! forwarded gate back ON for every member of a workspace build. A feature is a
25//! property of the BUILD; what the question needs is a property of the BINARY.
26//!
27//! ## A funnel is not always a board method — issues 1048 and 1123
28//!
29//! The search above kept losing for one more reason, and it is worth naming
30//! because it defeats the obvious grep: **not every boot funnel lives in a
31//! board crate.** `nros::main!` emits a different entry shape per framework,
32//! and two of them reach no `BoardEntry` method at all —
33//!
34//! * `Framework::Zephyr` (and its single-node sibling
35//!   `nros::zephyr_component_main!`) emits `rust_main` directly, because Zephyr
36//!   owns the C `main` and a Rust staticlib cannot take it over. That is why
37//!   `nros-board-zephyr` has only `run_tiers` to install from, and why a
38//!   single-tier pure-Rust Zephyr image published no sink list for two phases
39//!   (issue 1123) while its `log::info!`s worked fine.
40//! * a `[[bridge]]` system replaces the register/spin body with
41//!   `nros_bridge::run_from_config_str`, so it reaches no board either.
42//!
43//! Both now call `nros_platform::log::init_default()` — reached through
44//! `nros-platform` (which re-exports `nros_platform_cffi::log`) because that is
45//! the crate a generated Entry and the Zephyr example leaves already declare;
46//! giving each leaf a `nros-platform-cffi` dep of its own is the link-time
47//! requirement 0710 rejected.
48//!
49//! So the enumeration is **board crates PLUS the framework arms of
50//! `nros-macros`' `main_macro.rs` PLUS `nros`' entry macros**. Sweep it with:
51//!
52//! ```text
53//! grep -rn 'Framework::[A-Za-z]* => ' packages/core/nros-macros/src/main_macro.rs
54//! grep -rn 'init_default()' packages/boards packages/core/nros-macros packages/api/nros
55//! ```
56//!
57//! ## What this does instead
58//!
59//! Hold the records. A record raised with no sinks installed is copied into a
60//! bounded static ring here; [`crate::init`] drains it into whatever sinks the
61//! board actually chose. Nothing is dropped, no board can forget, and this
62//! crate touches no platform symbol to do it — the facade stays a facade.
63//!
64//! It is also STRICTLY better than installing a default sink was: the early
65//! records land in the sink the board picked, rather than in whichever one
66//! dispatch guessed before the board had spoken.
67//!
68//! ## Cost, and how to decline it
69//!
70//! EARLY_DEPTH * (format buffer + name + header) of static RAM, all of it in
71//! `.bss`. The depth is the `NROS_LOG_EARLY_RECORDS` knob (issue 1037; builtin
72//! 4, resolved by `build.rs` on the RFC-0049 ladder — it replaced the
73//! `early-records-<N>` features), because a 64 KB MCU and a Linux host do not
74//! want the same number. `0` declines the buffer entirely and restores the
75//! pre-0708 behaviour of dropping, with the count below still kept so the loss
76//! is at least reportable.
77//!
78//! Overflow is counted, never silently absorbed: [`overflowed`] returns how
79//! many records did not fit, and the next delivery reports the count through
80//! the sinks (`nros_log` logger, at `warn`).
81//!
82//! ## The same queue holds records that met a BUSY dispatcher — issue 2000
83//!
84//! Dispatch runs one record at a time: a flag is held across every
85//! `sink.log()`, so a sink that logs cannot recurse into itself, and two
86//! threads never write to a sink at once. Until issue 2000 a record that found
87//! the flag held was DROPPED, which was right for the recursion it was written
88//! for and wrong for everything else: a record from ANOTHER thread vanished
89//! whenever any thread was mid-delivery. `nros-log` cannot tell the two apart
90//! without a thread identity, which no `no_std` target here gives it for free.
91//!
92//! So such a record is held here instead, and the thread holding the flag
93//! delivers it before letting go. Nobody waits and nothing interleaves inside
94//! a sink. Two properties follow, both bounded on purpose:
95//!
96//! * **A sink that logs is not refused, it is postponed.** The holder makes at
97//!   most [`DRAIN_PASSES`] passes, each over the records already held when the
98//!   pass began, so a sink that logs on every write adds a bounded number of
99//!   lines per dispatch and can never loop or grow the stack. What is left
100//!   waits for the next dispatch or [`crate::flush`].
101//! * **A record cannot be stranded by the hand-off.** The raiser publishes the
102//!   record, then tries the flag once more; the holder releases the flag, then
103//!   looks for held records once more. Under `SeqCst` one of the two always
104//!   sees the other, so either the raiser delivers it or the holder does. The
105//!   only record left behind is one raised during the holder's LAST pass, which
106//!   the next dispatch or `flush` delivers.
107//!
108//! The knob sizes both uses, because they are one question: how many records
109//! may be waiting for a sink at once.
110
111use core::cell::UnsafeCell;
112
113use portable_atomic::{AtomicU8, AtomicUsize, Ordering};
114
115use crate::{Record, Severity, buffer::format_buffer_capacity};
116
117/// Records held at once. See the module docs for the trade.
118#[must_use]
119pub const fn early_depth() -> usize {
120    crate::config::EARLY_RECORDS
121}
122
123/// Passes a dispatcher makes over the held records before it lets go. See the
124/// module docs: this is what bounds a sink that logs.
125pub const DRAIN_PASSES: usize = 2;
126
127const DEPTH: usize = early_depth();
128const MSG_CAP: usize = format_buffer_capacity();
129/// A logger name is an identifier, not prose — `nros-node`'s longest is 20.
130const NAME_CAP: usize = 48;
131
132/// Slot states. A slot cycles `EMPTY -> WRITING -> READY -> READING -> EMPTY`,
133/// and each arrow is taken by exactly one thread (a CAS for the two claims).
134const EMPTY: u8 = 0;
135const WRITING: u8 = 1;
136const READY: u8 = 2;
137const READING: u8 = 3;
138
139struct Pending {
140    severity: Severity,
141    logger_name: heapless::String<NAME_CAP>,
142    message: heapless::String<MSG_CAP>,
143    file: &'static str,
144    line: u32,
145    timestamp_ns: u64,
146}
147
148impl Pending {
149    const fn new() -> Self {
150        Self {
151            severity: Severity::Info,
152            logger_name: heapless::String::new(),
153            message: heapless::String::new(),
154            file: "",
155            line: 0,
156            timestamp_ns: 0,
157        }
158    }
159}
160
161struct Slot {
162    /// Written only by the thread that moved `state` EMPTY -> WRITING, read
163    /// only by the one that moved it READY -> READING.
164    cell: UnsafeCell<Pending>,
165    state: AtomicU8,
166    /// Raise order, so a drain replays in the order records were raised.
167    seq: AtomicUsize,
168}
169
170// SAFETY: `cell` has one owner at a time, handed over by `state`: a writer owns
171// it from its EMPTY -> WRITING CAS to its `Release` store of READY, a reader
172// from its READY -> READING CAS (`Acquire`) to its `Release` store of EMPTY.
173unsafe impl Sync for Slot {}
174
175impl Slot {
176    const fn new() -> Self {
177        Self {
178            cell: UnsafeCell::new(Pending::new()),
179            state: AtomicU8::new(EMPTY),
180            seq: AtomicUsize::new(0),
181        }
182    }
183}
184
185#[allow(clippy::declare_interior_mutable_const)]
186const EMPTY_SLOT: Slot = Slot::new();
187static SLOTS: [Slot; DEPTH] = [EMPTY_SLOT; DEPTH];
188
189/// Next raise-order number.
190static NEXT_SEQ: AtomicUsize = AtomicUsize::new(0);
191/// READY slots. `SeqCst` on both sides: it is half of the hand-off in the
192/// module docs, paired with the dispatch flag.
193static PENDING: AtomicUsize = AtomicUsize::new(0);
194/// Records that found every slot taken. Never reset.
195static LOST: AtomicUsize = AtomicUsize::new(0);
196
197/// How many records did not fit and were lost, since the program started.
198#[must_use]
199pub fn overflowed() -> usize {
200    LOST.load(Ordering::Relaxed)
201}
202
203/// Raise a record with `emit` until `delivered()` sees it, re-raising it
204/// whenever [`overflowed`] counts a loss, for as long as `keep_waiting()`
205/// says to. Returns whether `delivered()` saw it.
206///
207/// For a caller that must OBSERVE a record it raised — a test asserting on
208/// what reached its sink. A record raised while another thread is delivering
209/// is held, and one that does not fit is dropped and counted, so in a
210/// multi-threaded test binary a record can be lost outright and no amount of
211/// waiting or [`crate::flush`] delivers it. Measured on `nros-node`'s lib
212/// tests: 8 copies at `--test-threads=64` failed 1 run in 48, with 16 records
213/// counted lost, the asserted ones among them.
214///
215/// A counted loss may have been a sibling's record while the caller's was
216/// still held, so a re-raise can deliver the record TWICE: assert on every
217/// copy, never on a count. `emit` must therefore be safe to repeat — a
218/// one-shot report (a latch it trips) has to reset its latch inside `emit`.
219///
220/// `keep_waiting` is the caller's budget and back-off, since this crate has
221/// no clock: a hosted test passes a deadline that yields,
222/// `|| { std::thread::yield_now(); Instant::now() < deadline }`.
223pub fn emit_until_delivered(
224    emit: impl Fn(),
225    delivered: impl Fn() -> bool,
226    mut keep_waiting: impl FnMut() -> bool,
227) -> bool {
228    loop {
229        let lost = overflowed();
230        emit();
231        loop {
232            crate::flush();
233            if delivered() {
234                return true;
235            }
236            if overflowed() != lost {
237                break;
238            }
239            if !keep_waiting() {
240                return delivered();
241            }
242        }
243        if !keep_waiting() {
244            return delivered();
245        }
246    }
247}
248
249/// Hold `record` until a dispatcher delivers it.
250///
251/// Returns `false` when it did not fit — the caller has nothing further to do,
252/// but [`overflowed`] counts it and the next delivery reports it.
253pub(crate) fn hold(record: &Record<'_>) -> bool {
254    for slot in &SLOTS {
255        if slot
256            .state
257            .compare_exchange(EMPTY, WRITING, Ordering::Acquire, Ordering::Relaxed)
258            .is_err()
259        {
260            continue;
261        }
262        slot.seq
263            .store(NEXT_SEQ.fetch_add(1, Ordering::Relaxed), Ordering::Relaxed);
264        // SAFETY: the CAS above made this thread the slot's only owner until
265        // the `Release` store of READY below.
266        let pending = unsafe { &mut *slot.cell.get() };
267        pending.severity = record.severity;
268        // Truncating rather than refusing: a clipped record is worth more than
269        // none, and the alternative is deciding that a long logger name loses
270        // the whole line.
271        pending.logger_name.clear();
272        let _ = pending
273            .logger_name
274            .push_str(clip(record.logger_name, NAME_CAP));
275        pending.message.clear();
276        let _ = pending.message.push_str(clip(record.message, MSG_CAP));
277        pending.file = record.file;
278        pending.line = record.line;
279        pending.timestamp_ns = record.timestamp_ns;
280        slot.state.store(READY, Ordering::Release);
281        PENDING.fetch_add(1, Ordering::SeqCst);
282        return true;
283    }
284    LOST.fetch_add(1, Ordering::Relaxed);
285    false
286}
287
288/// Whether any record is waiting.
289pub(crate) fn pending() -> bool {
290    PENDING.load(Ordering::SeqCst) > 0
291}
292
293/// Longest prefix of `s` that fits `cap` bytes without splitting a character.
294fn clip(s: &str, cap: usize) -> &str {
295    if s.len() <= cap {
296        return s;
297    }
298    let mut end = cap;
299    while end > 0 && !s.is_char_boundary(end) {
300        end -= 1;
301    }
302    &s[..end]
303}
304
305/// One pass: deliver every record held when the pass began, oldest first.
306///
307/// A record held DURING the pass waits for the next one — that is what bounds
308/// a sink that logs (module docs). Safe to run concurrently with `hold` and
309/// with another pass: a slot is claimed READY -> READING by one reader only.
310pub(crate) fn drain_with(deliver: &mut dyn FnMut(&Record<'_>)) {
311    let bound = NEXT_SEQ.load(Ordering::Acquire);
312    loop {
313        // Oldest READY slot raised before the pass began. DEPTH is a handful,
314        // so a scan per record is cheaper than any ordered structure.
315        let mut oldest: Option<(&Slot, usize)> = None;
316        for slot in &SLOTS {
317            if slot.state.load(Ordering::Acquire) != READY {
318                continue;
319            }
320            let seq = slot.seq.load(Ordering::Relaxed);
321            if seq >= bound {
322                continue;
323            }
324            if oldest.is_none_or(|(_, best)| seq < best) {
325                oldest = Some((slot, seq));
326            }
327        }
328        let Some((slot, _)) = oldest else { break };
329        if slot
330            .state
331            .compare_exchange(READY, READING, Ordering::Acquire, Ordering::Relaxed)
332            .is_err()
333        {
334            // Another pass took it first.
335            continue;
336        }
337        // SAFETY: the CAS above made this the slot's only reader, and its
338        // `Acquire` pairs with the writer's `Release` store of READY.
339        let pending = unsafe { &*slot.cell.get() };
340        let record = Record {
341            severity: pending.severity,
342            logger_name: pending.logger_name.as_str(),
343            message: pending.message.as_str(),
344            file: pending.file,
345            line: pending.line,
346            timestamp_ns: pending.timestamp_ns,
347        };
348        deliver(&record);
349        slot.state.store(EMPTY, Ordering::Release);
350        PENDING.fetch_sub(1, Ordering::SeqCst);
351    }
352}