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}