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//! ## 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}