Skip to main content

nros_log/
early.rs

1//! Records raised before any sink was installed.
2//!
3//! ## The problem this exists for
4//!
5//! [`crate::init`] publishes the sink list. A record raised before that call
6//! has nowhere to go, and for most of this crate's life it was constructed,
7//! dispatched and DROPPED — silently, and invisibly to its author, who cannot
8//! know what the board did before reaching their code.
9//!
10//! Issue 0708 answered by requiring every board boot funnel to call
11//! `init_default()`, gated on funnels spelled `pub fn run*`. That is a SEARCH
12//! for boot paths and it kept losing: NuttX's funnel is `pub extern "C" fn
13//! nsh_main`, and three board crates did not link `nros-log` in the
14//! configuration holding the funnel at all.
15//!
16//! Issue 0710 answered by having dispatch install the platform sink itself.
17//! That removed the search — but it put `nros_platform_log_write` on a path
18//! EVERY binary executes, which turned a pluggable delivery into a LINK-TIME
19//! requirement. Seven test targets in `nros-rmw-cffi` and most of `nros-tests`
20//! stopped linking under `check-workspace-features`, and no Cargo feature can
21//! fix that: `nros-platform-cffi` and `nros-rmw-bridge` enable
22//! `nros-node/rmw-cffi` unconditionally, so feature unification turns any
23//! forwarded gate back ON for every member of a workspace build. A feature is a
24//! property of the BUILD; what the question needs is a property of the BINARY.
25//!
26//! ## A funnel is not always a board method — issues 1048 and 1123
27//!
28//! The search above kept losing for one more reason, and it is worth naming
29//! because it defeats the obvious grep: **not every boot funnel lives in a
30//! board crate.** `nros::main!` emits a different entry shape per framework,
31//! and two of them reach no `BoardEntry` method at all —
32//!
33//! * `Framework::Zephyr` (and its single-node sibling
34//!   `nros::zephyr_component_main!`) emits `rust_main` directly, because Zephyr
35//!   owns the C `main` and a Rust staticlib cannot take it over. That is why
36//!   `nros-board-zephyr` has only `run_tiers` to install from, and why a
37//!   single-tier pure-Rust Zephyr image published no sink list for two phases
38//!   (issue 1123) while its `log::info!`s worked fine.
39//! * a `[[bridge]]` system replaces the register/spin body with
40//!   `nros_bridge::run_from_config_str`, so it reaches no board either.
41//!
42//! Both now call `nros_platform::log::init_default()` — reached through
43//! `nros-platform` (which re-exports `nros_platform_cffi::log`) because that is
44//! the crate a generated Entry and the Zephyr example leaves already declare;
45//! giving each leaf a `nros-platform-cffi` dep of its own is the link-time
46//! requirement 0710 rejected.
47//!
48//! So the enumeration is **board crates PLUS the framework arms of
49//! `nros-macros`' `main_macro.rs` PLUS `nros`' entry macros**. Sweep it with:
50//!
51//! ```text
52//! grep -rn 'Framework::[A-Za-z]* => ' packages/core/nros-macros/src/main_macro.rs
53//! grep -rn 'init_default()' packages/boards packages/core/nros-macros packages/api/nros
54//! ```
55//!
56//! ## What this does instead
57//!
58//! Hold the records. A record raised with no sinks installed is copied into a
59//! bounded static ring here; [`crate::init`] drains it into whatever sinks the
60//! board actually chose. Nothing is dropped, no board can forget, and this
61//! crate touches no platform symbol to do it — the facade stays a facade.
62//!
63//! It is also STRICTLY better than installing a default sink was: the early
64//! records land in the sink the board picked, rather than in whichever one
65//! dispatch guessed before the board had spoken.
66//!
67//! ## Cost, and how to decline it
68//!
69//! `EARLY_DEPTH * (format buffer + name + header)` of static RAM, all of it in
70//! `.bss`. The depth is the `NROS_LOG_EARLY_RECORDS` knob (issue 1037; builtin
71//! 4, resolved by `build.rs` on the RFC-0049 ladder — it replaced the
72//! `early-records-<N>` features), because a 64 KB MCU and a Linux host do not
73//! want the same number. `0` declines the buffer entirely and restores the
74//! pre-0708 behaviour of dropping, with the count below still kept so the loss
75//! is at least reportable.
76//!
77//! Overflow is counted, never silently absorbed: [`overflowed`] returns how
78//! many records did not fit, and `init` reports it through the freshly
79//! installed sinks.
80
81use core::cell::UnsafeCell;
82
83use portable_atomic::{AtomicUsize, Ordering};
84
85use crate::{LogSink, Record, Severity, buffer::format_buffer_capacity};
86
87/// Records held before `init`. See the module docs for the trade.
88#[must_use]
89pub const fn early_depth() -> usize {
90    crate::config::EARLY_RECORDS
91}
92
93const DEPTH: usize = early_depth();
94const MSG_CAP: usize = format_buffer_capacity();
95/// A logger name is an identifier, not prose — `nros-node`'s longest is 20.
96const NAME_CAP: usize = 48;
97
98struct Pending {
99    severity: Severity,
100    logger_name: heapless::String<NAME_CAP>,
101    message: heapless::String<MSG_CAP>,
102    file: &'static str,
103    line: u32,
104    timestamp_ns: u64,
105}
106
107impl Pending {
108    const fn new() -> Self {
109        Self {
110            severity: Severity::Info,
111            logger_name: heapless::String::new(),
112            message: heapless::String::new(),
113            file: "",
114            line: 0,
115            timestamp_ns: 0,
116        }
117    }
118}
119
120struct Slot {
121    /// Written by exactly one claimant; see `CLAIMED`.
122    cell: UnsafeCell<Pending>,
123    /// Publishes `cell` to the drain. `Release` here, `Acquire` there.
124    ready: AtomicUsize,
125}
126
127// SAFETY: `cell` is written only by the thread that won a distinct index from
128// `CLAIMED`'s `fetch_add` (each index is handed out once), and read only after
129// that thread's `Release` store to `ready` is observed by an `Acquire` load.
130unsafe impl Sync for Slot {}
131
132impl Slot {
133    const fn new() -> Self {
134        Self {
135            cell: UnsafeCell::new(Pending::new()),
136            ready: AtomicUsize::new(0),
137        }
138    }
139}
140
141#[allow(clippy::declare_interior_mutable_const)]
142const EMPTY_SLOT: Slot = Slot::new();
143static SLOTS: [Slot; DEPTH] = [EMPTY_SLOT; DEPTH];
144
145/// Total records offered while no sinks were installed. Indices `>= DEPTH`
146/// did not fit; the count of those is [`overflowed`].
147static CLAIMED: AtomicUsize = AtomicUsize::new(0);
148
149/// How many early records did not fit and were lost.
150#[must_use]
151pub fn overflowed() -> usize {
152    CLAIMED.load(Ordering::Relaxed).saturating_sub(DEPTH)
153}
154
155/// Hold `record` until a sink list is installed.
156///
157/// Returns `false` when it did not fit — the caller has nothing further to do,
158/// but [`overflowed`] will report it.
159// `early-records-0` makes the capacity 0, a legal size: the comparison is then
160// always true, which is the right answer for a pool with no slots.
161#[allow(clippy::absurd_extreme_comparisons)]
162pub(crate) fn hold(record: &Record<'_>) -> bool {
163    let idx = CLAIMED.fetch_add(1, Ordering::AcqRel);
164    if idx >= DEPTH {
165        return false;
166    }
167    let slot = &SLOTS[idx];
168    // SAFETY: `idx` was handed out exactly once by the `fetch_add` above, so
169    // this thread is the only writer of `slot.cell`, and no reader may touch it
170    // until the `Release` store below.
171    let pending = unsafe { &mut *slot.cell.get() };
172    pending.severity = record.severity;
173    // Truncating rather than refusing: a clipped early record is worth more
174    // than none, and the alternative is deciding at boot that a long logger
175    // name loses the whole line.
176    let _ = pending
177        .logger_name
178        .push_str(clip(record.logger_name, NAME_CAP));
179    let _ = pending.message.push_str(clip(record.message, MSG_CAP));
180    pending.file = record.file;
181    pending.line = record.line;
182    pending.timestamp_ns = record.timestamp_ns;
183    slot.ready.store(1, Ordering::Release);
184    true
185}
186
187/// Longest prefix of `s` that fits `cap` bytes without splitting a character.
188fn clip(s: &str, cap: usize) -> &str {
189    if s.len() <= cap {
190        return s;
191    }
192    let mut end = cap;
193    while end > 0 && !s.is_char_boundary(end) {
194        end -= 1;
195    }
196    &s[..end]
197}
198
199/// Replay everything held, in the order it was raised, into `sinks`.
200///
201/// Called by [`crate::init`] AFTER the sink list is published, so a record
202/// raised concurrently with the drain reaches the sinks directly rather than
203/// the ring. That can interleave a live record with a replayed one; ordering
204/// among the replayed records themselves is preserved, which is the property
205/// worth having.
206pub(crate) fn drain(sinks: &'static [&'static dyn LogSink]) {
207    drain_with(&mut |record| {
208        for sink in sinks {
209            sink.log(record);
210        }
211    });
212}
213
214/// The half of [`drain`] that does not name a sink LIST.
215///
216/// phase-417 W4.d added [`crate::add_sink`], so "where a held record goes" is
217/// no longer answered by one `&'static [&dyn LogSink]`. The replay walk stayed
218/// one function and grew a callback rather than being copied next to the new
219/// registry — a second copy is a second answer to "in what order, and what
220/// happens to a slot whose writer has not published yet".
221pub(crate) fn drain_with(deliver: &mut dyn FnMut(&Record<'_>)) {
222    let claimed = CLAIMED.load(Ordering::Acquire);
223    let held = if claimed > DEPTH { DEPTH } else { claimed };
224    for slot in SLOTS.iter().take(held) {
225        if slot.ready.swap(0, Ordering::AcqRel) == 0 {
226            // Either already drained by a concurrent `init`, or its writer has
227            // not published yet. Neither is worth spinning at boot for.
228            continue;
229        }
230        // SAFETY: the `Acquire` half of the swap above pairs with the writer's
231        // `Release` store, and the swap makes this the only reader.
232        let pending = unsafe { &*slot.cell.get() };
233        let record = Record {
234            severity: pending.severity,
235            logger_name: pending.logger_name.as_str(),
236            message: pending.message.as_str(),
237            file: pending.file,
238            line: pending.line,
239            timestamp_ns: pending.timestamp_ns,
240        };
241        deliver(&record);
242    }
243    CLAIMED.store(0, Ordering::Release);
244}