nros_log/lib.rs
1//! Phase 88 — portable leveled-logging facade for nano-ros.
2//!
3//! See [`docs/roadmap/archived/phase-88-nros-log.md`](../../../docs/roadmap/archived/phase-88-nros-log.md)
4//! for the design and acceptance criteria.
5//!
6//! ## Layering
7//!
8//! - This crate carries only the portable types + dispatcher +
9//! macros + `PlatformSink`. No backend code.
10//! - Per-platform log delivery is the responsibility of each
11//! `nros-platform-<rtos>` crate, exposing
12//! `nros_platform_log_write` / `nros_platform_log_flush` via the
13//! `nros_platform_*` ABI (header at
14//! `packages/platform/nros-platform-api/include/nros/platform.h`).
15//! - `PlatformSink` is the bridge: a single `LogSink` impl that
16//! forwards to the ABI. Apps that want fan-out (e.g.
17//! `Platform + /rosout`) compose a `&'static [&dyn LogSink]`
18//! manually and pass it to [`init`].
19//!
20//! ## Quick start
21//!
22//! ```ignore
23//! use nros_log::{Logger, Severity};
24//! use nros_log::{nros_info, nros_warn};
25//!
26//! static LOGGER: Logger = Logger::new("my_node");
27//!
28//! fn main() {
29//! nros_log::register_logger(&LOGGER);
30//! nros_log::init(nros_log::sinks::default());
31//! nros_info!(&LOGGER, "started; domain = {}", 42);
32//! }
33//! ```
34
35#![cfg_attr(not(feature = "std"), no_std)]
36#![deny(unsafe_op_in_unsafe_fn)]
37#![warn(missing_docs)]
38
39#[cfg(feature = "alloc")]
40extern crate alloc;
41
42// Phase 88.16.E — portable-atomic polyfill for CAS-less targets
43// (RISC-V `imc`, etc.). Feature unification: a consuming bare-metal
44// crate enables `unsafe-assume-single-core` / `critical-section` on
45// its own `portable-atomic` dep; native CAS targets get the
46// passthrough.
47use portable_atomic::{AtomicPtr, AtomicU8, Ordering};
48
49pub mod early;
50#[cfg(feature = "log-compat")]
51pub mod log_compat;
52pub mod macros;
53pub mod sinks;
54
55mod buffer;
56
57pub use buffer::{FormatBuffer, format_buffer_capacity};
58
59/// REP-2012 severity levels, mirroring `rcutils_log_severity_t`.
60///
61/// The integer representation is stable and part of the ABI for
62/// `nros_platform_log_write`. Lower value = more verbose.
63#[repr(u8)]
64#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
65pub enum Severity {
66 /// Per-instruction granularity. Off unless `max-level-trace` is
67 /// the active ceiling.
68 Trace = 0,
69 /// Diagnostic information useful while developing.
70 Debug = 1,
71 /// Normal operation events worth surfacing once.
72 Info = 2,
73 /// Unexpected but recoverable conditions.
74 Warn = 3,
75 /// Errors the caller should surface; the system continues.
76 Error = 4,
77 /// Unrecoverable — the system is about to abort.
78 Fatal = 5,
79}
80
81impl Severity {
82 /// Short uppercase label suitable for log-line rendering.
83 #[must_use]
84 pub const fn as_str(self) -> &'static str {
85 match self {
86 Self::Trace => "TRACE",
87 Self::Debug => "DEBUG",
88 Self::Info => "INFO",
89 Self::Warn => "WARN",
90 Self::Error => "ERROR",
91 Self::Fatal => "FATAL",
92 }
93 }
94
95 /// Stable `u8` discriminant for cross-ABI use.
96 #[must_use]
97 pub const fn as_u8(self) -> u8 {
98 self as u8
99 }
100}
101
102/// Reconstruct a [`Severity`] from its `u8` discriminant.
103///
104/// Returns `None` for `> 5`.
105#[must_use]
106pub const fn severity_from_u8(value: u8) -> Option<Severity> {
107 match value {
108 0 => Some(Severity::Trace),
109 1 => Some(Severity::Debug),
110 2 => Some(Severity::Info),
111 3 => Some(Severity::Warn),
112 4 => Some(Severity::Error),
113 5 => Some(Severity::Fatal),
114 _ => None,
115 }
116}
117
118/// Compile-time ceiling check used by the `nros_*!` macros.
119///
120/// Returns `true` iff `severity` is allowed under the configured
121/// `max-level-*` feature.
122#[must_use]
123pub const fn severity_enabled_at_compile_time(severity: Severity) -> bool {
124 if cfg!(feature = "max-level-off") {
125 return false;
126 }
127 let ceiling = compile_time_ceiling();
128 (severity as u8) >= (ceiling as u8)
129}
130
131const fn compile_time_ceiling() -> Severity {
132 if cfg!(feature = "max-level-trace") {
133 Severity::Trace
134 } else if cfg!(feature = "max-level-debug") {
135 Severity::Debug
136 } else if cfg!(feature = "max-level-info") {
137 Severity::Info
138 } else if cfg!(feature = "max-level-warn") {
139 Severity::Warn
140 } else if cfg!(feature = "max-level-error") {
141 Severity::Error
142 } else {
143 // No ceiling feature = treat as `max-level-trace`.
144 Severity::Trace
145 }
146}
147
148/// One log entry, handed to each [`LogSink`].
149///
150/// `message` is already formatted — sinks must NOT re-format.
151#[derive(Debug)]
152pub struct Record<'a> {
153 /// Severity of the record.
154 pub severity: Severity,
155 /// Name of the originating [`Logger`].
156 pub logger_name: &'a str,
157 /// Formatted message text (no trailing newline).
158 pub message: &'a str,
159 /// File the macro invocation came from (`core::file!()`).
160 pub file: &'static str,
161 /// Line within `file` (`core::line!()`).
162 pub line: u32,
163 /// Monotonic timestamp in nanoseconds. `0` if unavailable.
164 pub timestamp_ns: u64,
165}
166
167/// Backend a log record is delivered to.
168///
169/// Implementations must be `Sync` so the dispatcher can hold them
170/// in `&'static [&dyn LogSink]`. ISR-safety is per-impl — see the
171/// table in `docs/roadmap/archived/phase-88-nros-log.md`.
172pub trait LogSink: Sync {
173 /// Render `record`. Called only when the record's severity passes
174 /// both the compile-time ceiling AND the [`Logger`]'s runtime
175 /// threshold.
176 fn log(&self, record: &Record<'_>);
177
178 /// Optional flush hook (default no-op).
179 fn flush(&self) {}
180}
181
182/// A named logger with a runtime severity threshold.
183///
184/// Threshold defaults to [`Severity::Info`]. Use [`register_logger`]
185/// to publish a `'static Logger` so multiple call sites with the
186/// same name share the same threshold.
187pub struct Logger {
188 name: &'static str,
189 level: AtomicU8,
190}
191
192impl Logger {
193 /// `const`-construct with the default threshold ([`Severity::Info`]).
194 #[must_use]
195 pub const fn new(name: &'static str) -> Self {
196 Self {
197 name,
198 level: AtomicU8::new(Severity::Info as u8),
199 }
200 }
201
202 /// `const`-construct with an explicit threshold.
203 #[must_use]
204 pub const fn with_level(name: &'static str, level: Severity) -> Self {
205 Self {
206 name,
207 level: AtomicU8::new(level as u8),
208 }
209 }
210
211 /// Logger name (used as `Record::logger_name`).
212 #[must_use]
213 pub const fn name(&self) -> &'static str {
214 self.name
215 }
216
217 /// Current runtime threshold.
218 #[must_use]
219 pub fn level(&self) -> Severity {
220 severity_from_u8(self.level.load(Ordering::Relaxed)).unwrap_or(Severity::Info)
221 }
222
223 /// Update the runtime threshold.
224 pub fn set_level(&self, level: Severity) {
225 self.level.store(level as u8, Ordering::Relaxed);
226 }
227
228 /// Whether a record at `severity` would be emitted by this
229 /// logger AT RUNTIME.
230 #[must_use]
231 pub fn is_enabled(&self, severity: Severity) -> bool {
232 (severity as u8) >= self.level.load(Ordering::Relaxed)
233 }
234
235 /// Hand `record` to every registered sink, after the runtime
236 /// threshold check.
237 ///
238 /// Macros call this; user code should not.
239 pub fn dispatch(&self, record: &Record<'_>) {
240 if !self.is_enabled(record.severity) {
241 return;
242 }
243 dispatch_to_sinks(record);
244 }
245}
246
247// -----------------------------------------------------------------------------
248// Static intern table for `get_logger("name")`. Bounded; no alloc.
249// -----------------------------------------------------------------------------
250
251/// Maximum number of named loggers that can be registered via
252/// [`register_logger`]. Beyond this, [`get_logger`] returns
253/// [`DEFAULT_LOGGER`].
254pub const MAX_LOGGERS: usize = 32;
255
256/// Catch-all logger returned when the requested name is not
257/// registered (or the intern table is full).
258pub static DEFAULT_LOGGER: Logger = Logger::new("nros");
259
260mod intern {
261 use super::{AtomicPtr, Logger, MAX_LOGGERS, Ordering};
262
263 pub(super) struct InternTable {
264 slots: [AtomicPtr<Logger>; MAX_LOGGERS],
265 }
266
267 impl InternTable {
268 pub(super) const fn new() -> Self {
269 // `AtomicPtr::new` is `const` on both `core::sync::atomic`
270 // and `portable_atomic`, so we can initialise the array
271 // by repeating the call rather than naming a `const` —
272 // which clippy flags as interior-mutable.
273 #[allow(clippy::declare_interior_mutable_const)]
274 const NULL: AtomicPtr<Logger> = AtomicPtr::new(core::ptr::null_mut());
275 Self {
276 slots: [NULL; MAX_LOGGERS],
277 }
278 }
279
280 pub(super) fn lookup(&self, name: &str) -> Option<&'static Logger> {
281 for slot in &self.slots {
282 let ptr = slot.load(Ordering::Acquire);
283 if ptr.is_null() {
284 return None;
285 }
286 // SAFETY: pointer published via Release after the
287 // owner constructed a `'static Logger`. The Acquire
288 // load synchronizes.
289 let logger: &'static Logger = unsafe { &*ptr };
290 if logger.name() == name {
291 return Some(logger);
292 }
293 }
294 None
295 }
296
297 pub(super) fn insert(&self, logger: &'static Logger) -> Option<&'static Logger> {
298 if let Some(existing) = self.lookup(logger.name()) {
299 return Some(existing);
300 }
301 let ptr = logger as *const _ as *mut Logger;
302 for slot in &self.slots {
303 if slot
304 .compare_exchange(
305 core::ptr::null_mut(),
306 ptr,
307 Ordering::AcqRel,
308 Ordering::Acquire,
309 )
310 .is_ok()
311 {
312 return Some(logger);
313 }
314 let existing_ptr = slot.load(Ordering::Acquire);
315 if !existing_ptr.is_null() {
316 // SAFETY: same publication invariant as `lookup`.
317 let existing: &'static Logger = unsafe { &*existing_ptr };
318 if existing.name() == logger.name() {
319 return Some(existing);
320 }
321 }
322 }
323 None
324 }
325 }
326}
327
328static INTERN: intern::InternTable = intern::InternTable::new();
329
330/// Publish `logger` under its name so subsequent `get_logger`
331/// calls with that name return THIS reference.
332///
333/// On name collision returns the pre-existing entry (the input
334/// `logger` is NOT inserted). On a full table returns
335/// [`DEFAULT_LOGGER`].
336pub fn register_logger(logger: &'static Logger) -> &'static Logger {
337 INTERN.insert(logger).unwrap_or(&DEFAULT_LOGGER)
338}
339
340/// Look up a registered logger by name. Returns [`DEFAULT_LOGGER`]
341/// if none is registered (call [`register_logger`] for a `'static
342/// Logger` to publish one).
343///
344/// Total — every call returns a usable handle the macros can
345/// dispatch through.
346#[must_use]
347pub fn get_logger(name: &str) -> &'static Logger {
348 INTERN.lookup(name).unwrap_or(&DEFAULT_LOGGER)
349}
350
351// -----------------------------------------------------------------------------
352// Sink list. Set once at `init`; read every dispatch.
353// -----------------------------------------------------------------------------
354
355static SINKS_PTR: AtomicPtr<&'static [&'static dyn LogSink]> =
356 AtomicPtr::new(core::ptr::null_mut());
357
358// issue 0710 — `init_default()` was here. It named a default this crate can no
359// longer name: the platform sink moved to `nros_platform_cffi::log`, where the
360// ABI it speaks is a dependency rather than a feature. Call
361// `nros_platform_cffi::log::init_default()`, or `init()` with your own sinks.
362
363/// Install the global sink list.
364///
365/// MUST be called at app startup BEFORE any record-emitting macro
366/// runs (otherwise the dispatch is a no-op — records are silently
367/// dropped). Calling `init` more than once swaps the list
368/// atomically; the previous pointer is leaked (intentional: the
369/// read path is lock-free so we can't safely free).
370///
371/// The sinks themselves must outlive the program (`'static`).
372pub fn init(sinks: &'static [&'static dyn LogSink]) {
373 // Indirect through a small `'static` cell so the read path
374 // dereferences a fat-pointer-sized slot rather than reading
375 // a wide pointer atomically.
376 #[cfg(feature = "alloc")]
377 {
378 let boxed: alloc::boxed::Box<&'static [&'static dyn LogSink]> =
379 alloc::boxed::Box::new(sinks);
380 let ptr = alloc::boxed::Box::into_raw(boxed);
381 SINKS_PTR.store(ptr, Ordering::Release);
382 }
383 #[cfg(not(feature = "alloc"))]
384 {
385 static CELL: SinkSlot = SinkSlot::new();
386 CELL.store(sinks);
387 SINKS_PTR.store(CELL.as_ptr(), Ordering::Release);
388 }
389 // AFTER publishing, so a record raised during the drain reaches `sinks`
390 // directly rather than joining a ring nobody will drain again.
391 early::drain(sinks);
392 let lost = early::overflowed();
393 if lost > 0 {
394 // Reported through the sinks just installed, because the alternative is
395 // a silent hole exactly where the boot story is (`nros-log` cannot know
396 // what those records said, but it does know how many there were).
397 let logger = Logger::new("nros_log");
398 crate::nros_warn!(
399 &logger,
400 "{lost} record(s) raised before `init` did not fit the early ring \
401 (see `nros_log::early`; raise `early-records-<N>`)"
402 );
403 }
404}
405
406#[cfg(not(feature = "alloc"))]
407struct SinkSlot {
408 inner: core::cell::UnsafeCell<Option<&'static [&'static dyn LogSink]>>,
409}
410
411#[cfg(not(feature = "alloc"))]
412// SAFETY: only written from `init`, which the user contracts to call
413// once at startup before any concurrent reader exists.
414unsafe impl Sync for SinkSlot {}
415
416#[cfg(not(feature = "alloc"))]
417impl SinkSlot {
418 const fn new() -> Self {
419 Self {
420 inner: core::cell::UnsafeCell::new(None),
421 }
422 }
423 fn store(&self, sinks: &'static [&'static dyn LogSink]) {
424 // SAFETY: see Sync note above.
425 unsafe {
426 *self.inner.get() = Some(sinks);
427 }
428 }
429 fn as_ptr(&self) -> *mut &'static [&'static dyn LogSink] {
430 self.inner.get().cast()
431 }
432}
433
434/// Current monotonic time for `Record::timestamp_ns` (issue #503).
435///
436/// With the `platform-clock` feature this reads
437/// `nros_platform_clock_ns` — the universal per-platform export the
438/// executor's timer accounting already links — scaled to nanoseconds.
439/// Without the feature it returns `0` ("unavailable"), the historical
440/// behavior, and imposes no link-time requirement.
441///
442/// Public because the emission macros expand it in user crates; not
443/// part of the supported API surface.
444#[doc(hidden)]
445#[must_use]
446pub fn __timestamp_ns() -> u64 {
447 #[cfg(feature = "platform-clock")]
448 {
449 unsafe extern "C" {
450 fn nros_platform_clock_ns() -> u64;
451 }
452 // SAFETY: bare query of the platform's monotonic us counter;
453 // the symbol comes from whichever `nros-platform-<rtos>` port
454 // linked the binary (the contract `PlatformSink ->
455 // nros_platform_log_write` already relies on).
456 unsafe { nros_platform_clock_ns() }
457 }
458 #[cfg(not(feature = "platform-clock"))]
459 {
460 0
461 }
462}
463
464fn dispatch_to_sinks(record: &Record<'_>) {
465 if recursion_guard_check_and_set() {
466 return;
467 }
468 let ptr = SINKS_PTR.load(Ordering::Acquire);
469 if ptr.is_null() {
470 // Nothing installed yet — HOLD the record; `init` replays it into
471 // whatever sinks the board chooses. See `early` for why this crate
472 // does not reach for the platform sink itself: doing that (issue 0710)
473 // put `nros_platform_log_write` on a path every binary executes, which
474 // turned a pluggable delivery into a link-time requirement that no
475 // Cargo feature can undo under workspace feature unification.
476 //
477 // It is also a better answer to issue 0708 than installing a default
478 // was: the early records land in the sink the board picked, not in the
479 // one dispatch guessed before the board had spoken.
480 early::hold(record);
481 recursion_guard_clear();
482 return;
483 }
484 {
485 // SAFETY: `init` published a valid `'static` slice reference.
486 let sinks: &'static [&'static dyn LogSink] = unsafe { *ptr };
487 for sink in sinks {
488 sink.log(record);
489 }
490 }
491 recursion_guard_clear();
492}
493
494/// Flush every registered sink.
495pub fn flush() {
496 let ptr = SINKS_PTR.load(Ordering::Acquire);
497 if ptr.is_null() {
498 return;
499 }
500 // SAFETY: same invariant as `dispatch_to_sinks`.
501 let sinks: &'static [&'static dyn LogSink] = unsafe { *ptr };
502 for sink in sinks {
503 sink.flush();
504 }
505}
506
507// -----------------------------------------------------------------------------
508// Recursion guard — process-global single AtomicBool.
509//
510// Granularity is intentionally coarse (process-wide, not per-thread).
511// The guard exists to break a sink that triggers log() during write
512// — not to serialize concurrent loggers across threads. A thread
513// re-entering through its own sink loses its other in-flight
514// sinks for that call; a different thread logging concurrently is
515// also short-circuited momentarily. This is acceptable: the alt is
516// per-thread storage which doesn't exist uniformly across our
517// `no_std` targets (`thread_local!` requires `std`).
518// -----------------------------------------------------------------------------
519
520use portable_atomic::AtomicBool;
521static RECURSION_GUARD: AtomicBool = AtomicBool::new(false);
522
523fn recursion_guard_check_and_set() -> bool {
524 RECURSION_GUARD
525 .compare_exchange(false, true, Ordering::Acquire, Ordering::Acquire)
526 .is_err()
527}
528
529fn recursion_guard_clear() {
530 RECURSION_GUARD.store(false, Ordering::Release);
531}
532
533// -----------------------------------------------------------------------------
534// Tests (host-only).
535// -----------------------------------------------------------------------------
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn severity_round_trips_through_u8() {
543 for s in [
544 Severity::Trace,
545 Severity::Debug,
546 Severity::Info,
547 Severity::Warn,
548 Severity::Error,
549 Severity::Fatal,
550 ] {
551 assert_eq!(severity_from_u8(s.as_u8()), Some(s));
552 }
553 assert_eq!(severity_from_u8(99), None);
554 }
555
556 #[test]
557 fn logger_runtime_threshold_filters_below() {
558 let logger = Logger::with_level("test_thresh", Severity::Warn);
559 assert!(!logger.is_enabled(Severity::Info));
560 assert!(logger.is_enabled(Severity::Warn));
561 assert!(logger.is_enabled(Severity::Error));
562 logger.set_level(Severity::Debug);
563 assert!(logger.is_enabled(Severity::Info));
564 }
565
566 #[test]
567 fn unregistered_get_logger_returns_default() {
568 let l = get_logger("definitely-not-registered-99");
569 assert_eq!(l.name(), DEFAULT_LOGGER.name());
570 }
571
572 #[test]
573 fn registered_logger_round_trips_through_intern_table() {
574 static LOGGER: Logger = Logger::new("test_intern_round_trip");
575 let published = register_logger(&LOGGER);
576 assert_eq!(published.name(), LOGGER.name());
577 let looked_up = get_logger("test_intern_round_trip");
578 assert!(core::ptr::eq(published, looked_up));
579 }
580
581 #[test]
582 fn compile_time_ceiling_matches_enabled_feature() {
583 let expected = if cfg!(feature = "max-level-off") {
584 None
585 } else if cfg!(feature = "max-level-trace") {
586 Some(Severity::Trace)
587 } else if cfg!(feature = "max-level-debug") {
588 Some(Severity::Debug)
589 } else if cfg!(feature = "max-level-info") {
590 Some(Severity::Info)
591 } else if cfg!(feature = "max-level-warn") {
592 Some(Severity::Warn)
593 } else if cfg!(feature = "max-level-error") {
594 Some(Severity::Error)
595 } else {
596 // No ceiling feature = treat as `max-level-trace`.
597 Some(Severity::Trace)
598 };
599
600 for severity in [
601 Severity::Trace,
602 Severity::Debug,
603 Severity::Info,
604 Severity::Warn,
605 Severity::Error,
606 Severity::Fatal,
607 ] {
608 let enabled = expected.is_some_and(|ceiling| severity >= ceiling);
609 assert_eq!(severity_enabled_at_compile_time(severity), enabled);
610 }
611 }
612}