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//! ## What this does instead
27//!
28//! Hold the records. A record raised with no sinks installed is copied into a
29//! bounded static ring here; [`crate::init`] drains it into whatever sinks the
30//! board actually chose. Nothing is dropped, no board can forget, and this
31//! crate touches no platform symbol to do it — the facade stays a facade.
32//!
33//! It is also STRICTLY better than installing a default sink was: the early
34//! records land in the sink the board picked, rather than in whichever one
35//! dispatch guessed before the board had spoken.
36//!
37//! ## Cost, and how to decline it
38//!
39//! `EARLY_DEPTH * (format buffer + name + header)` of static RAM, all of it in
40//! `.bss`. The depth is chosen by the `early-records-<N>` feature family the
41//! same way `buffer-size-<N>` picks the format buffer, and for the same
42//! reason — a 64 KB MCU and a Linux host do not want the same number. `0`
43//! declines the buffer entirely and restores the pre-0708 behaviour of
44//! dropping, with the count below still kept so the loss is at least
45//! reportable.
46//!
47//! Overflow is counted, never silently absorbed: [`overflowed`] returns how
48//! many records did not fit, and `init` reports it through the freshly
49//! installed sinks.
50
51use core::cell::UnsafeCell;
52
53use portable_atomic::{AtomicUsize, Ordering};
54
55use crate::{LogSink, Record, Severity, buffer::format_buffer_capacity};
56
57/// Records held before `init`. See the module docs for the trade.
58#[must_use]
59pub const fn early_depth() -> usize {
60 if cfg!(feature = "early-records-0") {
61 0
62 } else if cfg!(feature = "early-records-16") {
63 16
64 } else if cfg!(feature = "early-records-8") {
65 8
66 } else {
67 4
68 }
69}
70
71const DEPTH: usize = early_depth();
72const MSG_CAP: usize = format_buffer_capacity();
73/// A logger name is an identifier, not prose — `nros-node`'s longest is 20.
74const NAME_CAP: usize = 48;
75
76struct Pending {
77 severity: Severity,
78 logger_name: heapless::String<NAME_CAP>,
79 message: heapless::String<MSG_CAP>,
80 file: &'static str,
81 line: u32,
82 timestamp_ns: u64,
83}
84
85impl Pending {
86 const fn new() -> Self {
87 Self {
88 severity: Severity::Info,
89 logger_name: heapless::String::new(),
90 message: heapless::String::new(),
91 file: "",
92 line: 0,
93 timestamp_ns: 0,
94 }
95 }
96}
97
98struct Slot {
99 /// Written by exactly one claimant; see `CLAIMED`.
100 cell: UnsafeCell<Pending>,
101 /// Publishes `cell` to the drain. `Release` here, `Acquire` there.
102 ready: AtomicUsize,
103}
104
105// SAFETY: `cell` is written only by the thread that won a distinct index from
106// `CLAIMED`'s `fetch_add` (each index is handed out once), and read only after
107// that thread's `Release` store to `ready` is observed by an `Acquire` load.
108unsafe impl Sync for Slot {}
109
110impl Slot {
111 const fn new() -> Self {
112 Self {
113 cell: UnsafeCell::new(Pending::new()),
114 ready: AtomicUsize::new(0),
115 }
116 }
117}
118
119#[allow(clippy::declare_interior_mutable_const)]
120const EMPTY_SLOT: Slot = Slot::new();
121static SLOTS: [Slot; DEPTH] = [EMPTY_SLOT; DEPTH];
122
123/// Total records offered while no sinks were installed. Indices `>= DEPTH`
124/// did not fit; the count of those is [`overflowed`].
125static CLAIMED: AtomicUsize = AtomicUsize::new(0);
126
127/// How many early records did not fit and were lost.
128#[must_use]
129pub fn overflowed() -> usize {
130 CLAIMED.load(Ordering::Relaxed).saturating_sub(DEPTH)
131}
132
133/// Hold `record` until a sink list is installed.
134///
135/// Returns `false` when it did not fit — the caller has nothing further to do,
136/// but [`overflowed`] will report it.
137pub(crate) fn hold(record: &Record<'_>) -> bool {
138 let idx = CLAIMED.fetch_add(1, Ordering::AcqRel);
139 if idx >= DEPTH {
140 return false;
141 }
142 let slot = &SLOTS[idx];
143 // SAFETY: `idx` was handed out exactly once by the `fetch_add` above, so
144 // this thread is the only writer of `slot.cell`, and no reader may touch it
145 // until the `Release` store below.
146 let pending = unsafe { &mut *slot.cell.get() };
147 pending.severity = record.severity;
148 // Truncating rather than refusing: a clipped early record is worth more
149 // than none, and the alternative is deciding at boot that a long logger
150 // name loses the whole line.
151 let _ = pending
152 .logger_name
153 .push_str(clip(record.logger_name, NAME_CAP));
154 let _ = pending.message.push_str(clip(record.message, MSG_CAP));
155 pending.file = record.file;
156 pending.line = record.line;
157 pending.timestamp_ns = record.timestamp_ns;
158 slot.ready.store(1, Ordering::Release);
159 true
160}
161
162/// Longest prefix of `s` that fits `cap` bytes without splitting a character.
163fn clip(s: &str, cap: usize) -> &str {
164 if s.len() <= cap {
165 return s;
166 }
167 let mut end = cap;
168 while end > 0 && !s.is_char_boundary(end) {
169 end -= 1;
170 }
171 &s[..end]
172}
173
174/// Replay everything held, in the order it was raised, into `sinks`.
175///
176/// Called by [`crate::init`] AFTER the sink list is published, so a record
177/// raised concurrently with the drain reaches the sinks directly rather than
178/// the ring. That can interleave a live record with a replayed one; ordering
179/// among the replayed records themselves is preserved, which is the property
180/// worth having.
181pub(crate) fn drain(sinks: &'static [&'static dyn LogSink]) {
182 let claimed = CLAIMED.load(Ordering::Acquire);
183 let held = if claimed > DEPTH { DEPTH } else { claimed };
184 for slot in SLOTS.iter().take(held) {
185 if slot.ready.swap(0, Ordering::AcqRel) == 0 {
186 // Either already drained by a concurrent `init`, or its writer has
187 // not published yet. Neither is worth spinning at boot for.
188 continue;
189 }
190 // SAFETY: the `Acquire` half of the swap above pairs with the writer's
191 // `Release` store, and the swap makes this the only reader.
192 let pending = unsafe { &*slot.cell.get() };
193 let record = Record {
194 severity: pending.severity,
195 logger_name: pending.logger_name.as_str(),
196 message: pending.message.as_str(),
197 file: pending.file,
198 line: pending.line,
199 timestamp_ns: pending.timestamp_ns,
200 };
201 for sink in sinks {
202 sink.log(&record);
203 }
204 }
205 CLAIMED.store(0, Ordering::Release);
206}