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::{log_info, log_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//! log_info!(&LOGGER, "started; domain = {}", 42);
32//! }
33//! ```
34
35// UNCONDITIONAL. This was `cfg_attr(not(feature = "std"), no_std)`, which
36// bought nothing: this crate's `std` feature is `std = ["alloc"]` over
37// `alloc = []`, so it enables no std functionality and the crate uses none.
38// The conditional form only meant that building with `std` compiled a
39// DIFFERENT crate -- a second configuration nobody wanted and no lane checked.
40//
41// ARCHITECTURE section 2 / phase-359: the terminal state of a core crate is
42// `core` or `core+alloc`. `check-core-crates-are-no-std` holds every core
43// crate to this spelling.
44#![no_std]
45#![deny(unsafe_op_in_unsafe_fn)]
46#![warn(missing_docs)]
47
48#[cfg(feature = "alloc")]
49extern crate alloc;
50
51// Phase 88.16.E — portable-atomic polyfill for CAS-less targets
52// (RISC-V `imc`, etc.). Feature unification: a consuming bare-metal
53// crate enables `unsafe-assume-single-core` / `critical-section` on
54// its own `portable-atomic` dep; native CAS targets get the
55// passthrough.
56use portable_atomic::{AtomicBool, AtomicPtr, AtomicU8, AtomicUsize, Ordering};
57
58pub mod early;
59#[cfg(feature = "log-compat")]
60pub mod log_compat;
61pub mod macros;
62pub mod pool;
63#[cfg(feature = "rosout")]
64pub mod rosout;
65pub mod sinks;
66pub mod throttle;
67
68mod buffer;
69
70/// The resolved `[knobs.log]` tenant, written by `build.rs` (phase-479 W5,
71/// issue 1037). One file, one place each value enters the crate.
72mod config {
73 include!(concat!(env!("OUT_DIR"), "/nros_log_config.rs"));
74
75 // `build.rs` range-checks the knob; this keeps a hand-edited or stale
76 // file from compiling into a ceiling above `off`.
77 #[allow(clippy::absurd_extreme_comparisons)]
78 const _: () = assert!(MAX_LEVEL <= 6, "NROS_LOG_MAX_LEVEL is 0..=6");
79}
80
81pub use buffer::{FormatBuffer, format_buffer_capacity};
82pub use pool::{
83 MAX_LOGGER_NAME_LEN, dynamic_logger_capacity, dynamic_logger_name_arena, dynamic_loggers_in_use,
84};
85pub use throttle::{ThrottleState, interval_ms_to_ns, throttle_decide};
86
87/// REP-2012 severity levels: rcutils's five, in rcutils's order, plus `Trace`.
88///
89/// NOT `rcutils_log_severity_t`'s numbering (`UNSET=0, DEBUG=10 ... FATAL=50`),
90/// and no `Unset` variant. The integer representation is stable and part of the
91/// ABI for `nros_platform_log_write`, so every value must be a level a sink can
92/// render; "no level of its own" is the verb [`Logger::unset_level`] instead.
93/// (The C surface's `rcl_log_severity_t` does carry rcutils's numbers and
94/// `UNSET`.) Lower value = more verbose.
95#[repr(u8)]
96#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
97pub enum Severity {
98 /// Per-instruction granularity. Off unless `max-level-trace` is
99 /// the active ceiling.
100 Trace = 0,
101 /// Diagnostic information useful while developing.
102 Debug = 1,
103 /// Normal operation events worth surfacing once.
104 Info = 2,
105 /// Unexpected but recoverable conditions.
106 Warn = 3,
107 /// Errors the caller should surface; the system continues.
108 Error = 4,
109 /// Unrecoverable — the system is about to abort.
110 Fatal = 5,
111}
112
113impl Severity {
114 /// Short uppercase label suitable for log-line rendering.
115 #[must_use]
116 pub const fn as_str(self) -> &'static str {
117 match self {
118 Self::Trace => "TRACE",
119 Self::Debug => "DEBUG",
120 Self::Info => "INFO",
121 Self::Warn => "WARN",
122 Self::Error => "ERROR",
123 Self::Fatal => "FATAL",
124 }
125 }
126
127 /// Stable `u8` discriminant for cross-ABI use.
128 #[must_use]
129 pub const fn as_u8(self) -> u8 {
130 self as u8
131 }
132
133 /// This severity on **rcutils's number line** — the one every ROS 2
134 /// artefact outside this crate uses.
135 ///
136 /// [`as_u8`](Self::as_u8) is this crate's own compact discriminant (`0..=5`,
137 /// chosen so a level comparison is an integer compare). It is NOT what a
138 /// `rcl_interfaces/msg/Log` carries, nor what `<rclc/log.h>`'s
139 /// `rcl_log_severity_t` crosses the FFI as, nor what
140 /// `rcutils_logging_set_logger_level` takes. Those are all rcutils's
141 /// `DEBUG=10 / INFO=20 / WARN=30 / ERROR=40 / FATAL=50`.
142 ///
143 /// THE ONE SPELLING of that mapping (issue 1025's class: one number, two
144 /// derivations, agreeing until the day they do not). `nros-c`'s
145 /// `rcl_log_severity_t::from_facade` is built on this, and a test there
146 /// pins the result against the values `<rclc/log.h>` declares — which is
147 /// the strongest join available, since this crate cannot see that header.
148 ///
149 /// `Trace` takes **5**, a value inside rcutils's own `UNSET`..`DEBUG` gap:
150 /// rcutils has no TRACE, and borrowing `DEBUG`'s number would make the two
151 /// levels indistinguishable on the wire.
152 #[must_use]
153 pub const fn rcutils_level(self) -> u8 {
154 match self {
155 Self::Trace => 5,
156 Self::Debug => 10,
157 Self::Info => 20,
158 Self::Warn => 30,
159 Self::Error => 40,
160 Self::Fatal => 50,
161 }
162 }
163}
164
165/// Reconstruct a [`Severity`] from its `u8` discriminant.
166///
167/// Returns `None` for `> 5`.
168#[must_use]
169pub const fn severity_from_u8(value: u8) -> Option<Severity> {
170 match value {
171 0 => Some(Severity::Trace),
172 1 => Some(Severity::Debug),
173 2 => Some(Severity::Info),
174 3 => Some(Severity::Warn),
175 4 => Some(Severity::Error),
176 5 => Some(Severity::Fatal),
177 _ => None,
178 }
179}
180
181/// Compile-time ceiling check used by the `nros_*!` macros.
182///
183/// Returns `true` iff `severity` is at or above the image's
184/// `NROS_LOG_MAX_LEVEL` (issue 1037; builtin `trace`, so everything). A
185/// `const fn` over a build-script constant, so a macro call below the ceiling
186/// folds to `if false { .. }` and its formatting is dead code — the elimination
187/// the old `max-level-*` features gave, without a feature.
188///
189/// The ceiling is a BOUND, not a threshold: [`Logger::set_level`] still moves
190/// each logger's runtime level, and a level below the ceiling is simply
191/// unreachable ([`Logger::is_enabled`] answers `false` there). rcutils keeps
192/// the same pair (`RCUTILS_LOG_MIN_SEVERITY_*` + `set_logger_level`).
193#[must_use]
194pub const fn severity_enabled_at_compile_time(severity: Severity) -> bool {
195 // `config::MAX_LEVEL` is 6 for `off`, above `Fatal` (5): nothing passes.
196 // The value is a build-script constant, and at its builtin (0 = trace) the
197 // comparison is always true — that is the point, not a slip.
198 #[allow(clippy::absurd_extreme_comparisons)]
199 let enabled = (severity as u8) >= config::MAX_LEVEL;
200 enabled
201}
202
203/// One log entry, handed to each [`LogSink`].
204///
205/// `message` is already formatted — sinks must NOT re-format.
206#[derive(Debug)]
207pub struct Record<'a> {
208 /// Severity of the record.
209 pub severity: Severity,
210 /// Name of the originating [`Logger`].
211 pub logger_name: &'a str,
212 /// Formatted message text (no trailing newline).
213 pub message: &'a str,
214 /// File the macro invocation came from (`core::file!()`).
215 pub file: &'static str,
216 /// Line within `file` (`core::line!()`).
217 pub line: u32,
218 /// Monotonic timestamp in nanoseconds. `0` if unavailable.
219 pub timestamp_ns: u64,
220}
221
222/// Backend a log record is delivered to.
223///
224/// Implementations must be `Sync` so the dispatcher can hold them
225/// in `&'static [&dyn LogSink]`. ISR-safety is per-impl — see the
226/// table in `docs/roadmap/archived/phase-88-nros-log.md`.
227pub trait LogSink: Sync {
228 /// Render `record`. Called only when the record's severity passes
229 /// both the compile-time ceiling AND the [`Logger`]'s runtime
230 /// threshold.
231 fn log(&self, record: &Record<'_>);
232
233 /// Optional flush hook (default no-op).
234 fn flush(&self) {}
235}
236
237/// The stored byte meaning "this logger has NO level of its own", so its
238/// threshold is whatever [`Logger::default_level`] currently says.
239///
240/// phase-467 (ledger rows `c:log_severity_t` + `rust:Logger::set_default_level`).
241/// It cannot be `0`: that is `Severity::Trace`, the most verbose level, which
242/// is why storing rcutils's `UNSET` in a level byte used to select TRACE here
243/// where upstream restores a default. `u8::MAX` is outside the enum in the
244/// other direction, and being ABOVE `Fatal` is load-bearing in
245/// [`Logger::is_enabled`] — see the note there.
246const LEVEL_UNSET: u8 = u8::MAX;
247
248/// The threshold a logger with no level of its own resolves to.
249///
250/// ONE process-wide byte, which is the whole of our level hierarchy. rcutils
251/// resolves an unset logger by walking a DOTTED ANCESTRY (`x.y.z` → `x.y` →
252/// `x`) to `g_rcutils_logging_default_logger_level`; `nros_log` has no
253/// ancestry — [`get_logger`] is exact string equality over [`MAX_LOGGERS`]
254/// slots — so that walk degenerates here to exactly one step, and one step is
255/// what this is.
256///
257/// Deliberately not built further, and phase-467 recorded that as PERMANENT on
258/// ledger row `c:log_severity_t` rather than as work. A dotted name is accepted
259/// and is just a name with a dot in it; what is missing is the PARENT, and the
260/// two things that would give it one are both absent for the same reason.
261/// Upstream keys a level by NAME (`rcutils_logging_set_logger_level(const char
262/// *, int)` writes an allocator-backed severity map), so an ancestor there can
263/// carry a level without being a logger; here the level is an `AtomicU8` INSIDE
264/// the [`Logger`], so an ancestor would have to occupy one of the 32 slots to
265/// hold one. And the producer of dotted names upstream, `Logger::get_child`,
266/// has no allocator here to concatenate `parent.child` with. A resolver for a
267/// hierarchy nothing can construct is not a missing feature.
268///
269/// RFC-0102 reversed that and phase-479 built it: the premises went stale
270/// ([`get_or_create_logger`] copies runtime names into a static arena,
271/// `parent.child` fits a 48-byte stack buffer, and the ancestors a `get_child`
272/// produces are loggers already). The hierarchy is now a parent pointer on
273/// [`Logger`], linked at creation, and this byte is the LAST step of the walk
274/// rather than the only one.
275static DEFAULT_LEVEL: AtomicU8 = AtomicU8::new(Severity::Info as u8);
276
277/// A named logger with a runtime severity threshold.
278///
279/// A logger constructed with [`Logger::new`] has NO level of its own and
280/// follows [`Logger::default_level`] (itself [`Severity::Info`] until someone
281/// calls [`Logger::set_default_level`]). [`Logger::with_level`] and
282/// [`Logger::set_level`] give it one; [`Logger::unset_level`] takes it away
283/// again. Use [`register_logger`] to publish a `'static Logger` so multiple
284/// call sites with the same name share the same threshold.
285pub struct Logger {
286 name: &'static str,
287 /// Either a [`Severity`] discriminant or the private `LEVEL_UNSET`.
288 level: AtomicU8,
289 /// RFC-0102 D2 — the nearest EXISTING ancestor by dotted name (`a.b` for
290 /// `a.b.c`, else `a`), or null. A logger with no level of its own resolves
291 /// through this chain before falling to [`Logger::default_level`], which is
292 /// rcutils's effective-level rule. Set when a logger is published to the
293 /// intern table ([`register_logger`], [`get_or_create_logger`]), never on
294 /// the log path. Only ever points at a logger whose name is a STRICT
295 /// dotted prefix of this one's, so the chain is acyclic and shorter than
296 /// [`MAX_LOGGERS`].
297 parent: AtomicPtr<Logger>,
298 /// RFC-0102 D4 — set when a NODE's accessor resolves this logger
299 /// ([`resolve_node_logger`]). The `/rosout` sink publishes by it: upstream
300 /// rcl publishes only through a publisher correlated with a node's logger.
301 node: AtomicBool,
302}
303
304impl Logger {
305 /// `const`-construct with NO level of its own — the logger follows
306 /// [`Logger::default_level`], which is [`Severity::Info`] until something
307 /// moves it.
308 ///
309 /// phase-467: this stored `Severity::Info` outright until the process
310 /// default existed, and "the author wrote `new`" then looked exactly like
311 /// "the author wrote `with_level(.., Info)`". They are different
312 /// statements — one is a choice and the other is the absence of one — and
313 /// telling them apart is what lets [`Logger::set_default_level`] move the
314 /// first without overriding the second.
315 #[must_use]
316 pub const fn new(name: &'static str) -> Self {
317 Self {
318 name,
319 level: AtomicU8::new(LEVEL_UNSET),
320 parent: AtomicPtr::new(core::ptr::null_mut()),
321 node: AtomicBool::new(false),
322 }
323 }
324
325 /// `const`-construct with an explicit threshold.
326 #[must_use]
327 pub const fn with_level(name: &'static str, level: Severity) -> Self {
328 Self {
329 name,
330 level: AtomicU8::new(level as u8),
331 parent: AtomicPtr::new(core::ptr::null_mut()),
332 node: AtomicBool::new(false),
333 }
334 }
335
336 /// Logger name (used as `Record::logger_name`).
337 #[must_use]
338 pub const fn name(&self) -> &'static str {
339 self.name
340 }
341
342 /// The threshold this logger actually filters on — its own level if it has
343 /// one, otherwise its nearest ancestor's (RFC-0102 D2), otherwise
344 /// [`Logger::default_level`].
345 ///
346 /// This is `rcutils_logging_get_logger_effective_level`'s answer, not
347 /// `rcutils_logging_get_logger_level`'s: it is the number that decides
348 /// whether a record is emitted, which is the question a call site asks.
349 /// There is deliberately no accessor for "is a level set on this logger",
350 /// because nothing in the tree has a use for the difference and an
351 /// accessor per language is three more names for one fact.
352 #[must_use]
353 pub fn level(&self) -> Severity {
354 severity_from_u8(self.effective_level_byte()).unwrap_or(Severity::Info)
355 }
356
357 /// The nearest ancestor this logger resolves an unset level through, if
358 /// any (RFC-0102 D2). `None` for a root, and for every logger not yet
359 /// published to the intern table.
360 #[must_use]
361 pub fn parent(&self) -> Option<&'static Logger> {
362 let p = self.parent.load(Ordering::Acquire);
363 // SAFETY: `parent` is only ever stored from a `&'static Logger` (see
364 // `link_published`), and loggers are never freed.
365 if p.is_null() {
366 None
367 } else {
368 Some(unsafe { &*p })
369 }
370 }
371
372 /// Whether a node's accessor resolved this logger (RFC-0102 D4).
373 #[must_use]
374 pub fn is_node_logger(&self) -> bool {
375 self.node.load(Ordering::Relaxed)
376 }
377
378 /// Own level byte if set; else the first set level up the parent chain;
379 /// else the process default. The walk is bounded by [`MAX_LOGGERS`] even
380 /// though the chain is acyclic by construction, so a defect elsewhere can
381 /// cost a wrong answer but never a hang on the log path.
382 #[inline]
383 fn effective_level_byte(&self) -> u8 {
384 let own = self.level.load(Ordering::Relaxed);
385 if own != LEVEL_UNSET {
386 return own;
387 }
388 let mut p = self.parent.load(Ordering::Acquire);
389 let mut hops = 0;
390 while !p.is_null() && hops < MAX_LOGGERS {
391 // SAFETY: as in `parent()`.
392 let ancestor: &Logger = unsafe { &*p };
393 let level = ancestor.level.load(Ordering::Relaxed);
394 if level != LEVEL_UNSET {
395 return level;
396 }
397 p = ancestor.parent.load(Ordering::Acquire);
398 hops += 1;
399 }
400 DEFAULT_LEVEL.load(Ordering::Relaxed)
401 }
402
403 /// Give this logger a level of its own. It stops following
404 /// [`Logger::default_level`] until [`Logger::unset_level`].
405 pub fn set_level(&self, level: Severity) {
406 self.level.store(level as u8, Ordering::Relaxed);
407 }
408
409 /// Take this logger's own level away, so it follows
410 /// [`Logger::default_level`] again.
411 ///
412 /// `rcutils_logging_set_logger_level(name, RCUTILS_LOG_SEVERITY_UNSET)`'s
413 /// effect, and what `rclc_logger_set_level(logger, RCLC_LOG_SEVERITY_UNSET)`
414 /// reaches. phase-467.
415 ///
416 /// rclrs spells this `logger.set_level(LogSeverity::Unset)`. Here it is a verb,
417 /// because [`Severity`] has no `Unset`: its values are the sink ABI.
418 pub fn unset_level(&self) {
419 self.level.store(LEVEL_UNSET, Ordering::Relaxed);
420 }
421
422 /// The threshold every logger with no level of its own filters on.
423 ///
424 /// [`Severity::Info`] until [`Logger::set_default_level`] moves it, which
425 /// is `RCUTILS_DEFAULT_LOGGER_DEFAULT_LEVEL`'s value and upstream's
426 /// starting point too.
427 #[must_use]
428 pub fn default_level() -> Severity {
429 severity_from_u8(DEFAULT_LEVEL.load(Ordering::Relaxed)).unwrap_or(Severity::Info)
430 }
431
432 /// Move the threshold that every logger WITHOUT a level of its own filters
433 /// on — `rclrs::Logger::set_default_level`.
434 ///
435 /// Takes effect immediately and retroactively: a `static LOGGER:
436 /// Logger = Logger::new("x")` that was compiled into the image long before
437 /// this call follows it, because "no level of its own" is a stored state
438 /// and not a value copied in at construction. That is the whole reason
439 /// this is a capability rather than a re-export of
440 /// `DEFAULT_LOGGER.set_level`, which moves the threshold of the ONE logger
441 /// named `"nros"` (ledger row `rust:Logger::set_default_level`).
442 ///
443 /// It does NOT override a logger that states its own level: after
444 /// `with_level(.., Warn)` or `set_level(Warn)`, `Warn` wins.
445 ///
446 /// Takes [`Severity`], not rclrs's `LogSeverity`: it has a `Trace` level rclrs
447 /// lacks and no `Unset`. Where rclrs passes `Unset` to restore the default
448 /// default, pass `Severity::Info`, which is what `Unset` restores upstream.
449 pub fn set_default_level(level: Severity) {
450 DEFAULT_LEVEL.store(level as u8, Ordering::Relaxed);
451 }
452
453 /// Whether a record at `severity` would be emitted by this
454 /// logger AT RUNTIME.
455 ///
456 /// THE HOT PATH of every log macro, on every target, so the shape here is
457 /// measured rather than chosen (phase-467; the figures below are for the
458 /// two-level own-or-default form that preceded RFC-0102's ancestor walk). Resolving the unset level
459 /// SELECTS the byte to compare against and compares once, instead of
460 /// comparing twice and short-circuiting: measured at an inlined call site
461 /// with `rustc -O`, the select form is 13 instructions on
462 /// `thumbv7m-none-eabi` and **branch-free** (LLVM predicates the whole
463 /// fallback into one `ittt eq` block) against 16 with a branch for the
464 /// short-circuit form, and 10 against 12 on `x86_64`. The cost over the
465 /// single-load original (8 thumb / 4 x86_64 instructions) is one compare
466 /// against an immediate, plus a byte load of one process-global that a
467 /// logger with its own level never performs.
468 ///
469 /// `LEVEL_UNSET` being ABOVE `Fatal` rather than below `Trace` is what
470 /// keeps this correct if the select is ever reordered: `severity >=
471 /// u8::MAX` is false for every `Severity`, so an unresolved sentinel can
472 /// only ever suppress a record, never emit one at the most verbose level —
473 /// which is the failure `0` had.
474 ///
475 /// RFC-0102 D2 changed the unset arm: it now walks the parent chain before
476 /// the process default. A logger WITH its own level still costs one load
477 /// and one compare; an unset root pays one extra pointer load; each
478 /// ancestor without a level of its own costs one more. No string work, on
479 /// any arm.
480 #[must_use]
481 ///
482 /// The image's compile-time CEILING (`NROS_LOG_MAX_LEVEL`, issue 1037) is
483 /// applied here too, first: a runtime level below it is unreachable. That
484 /// is what brings the C and C++ surfaces under the same bound — their
485 /// macros format on the C side and reach the facade through this check
486 /// (`rclc_log_emit_at`, `nros_log_is_enabled`), so the ceiling the Rust
487 /// macros fold away at compile time filters theirs at the FFI boundary.
488 pub fn is_enabled(&self, severity: Severity) -> bool {
489 severity_enabled_at_compile_time(severity)
490 && (severity as u8) >= self.effective_level_byte()
491 }
492
493 /// Whether a record at `severity` would be emitted, given both this
494 /// logger's runtime threshold AND a per-call-site throttle window.
495 ///
496 /// phase-417 W4.d. The order is load-bearing and is the same order
497 /// `rclcpp` uses: the SEVERITY test runs first, so a record the level
498 /// already filters does not consume the throttle window. Reversing them
499 /// makes a raised-then-lowered log level lose its first record for a whole
500 /// interval, which is the shape of bug nobody reports because it looks
501 /// like the throttle working.
502 ///
503 /// Consumes the window when it returns `true`, so call it exactly once per
504 /// candidate record — the `nros_*_throttle!` macros do.
505 ///
506 /// `now_ns` is the caller's monotonic clock; the macros pass
507 /// [`__timestamp_ns`]. Taking it as an argument rather than reading the
508 /// clock here is what lets the whole rule be tested without a platform
509 /// port, and is the same shape `rclcpp::RCLCPP_*_THROTTLE` uses when it
510 /// asks for a `Clock`.
511 #[must_use]
512 pub fn is_enabled_throttled(
513 &self,
514 severity: Severity,
515 state: &ThrottleState,
516 now_ns: u64,
517 interval_ns: u64,
518 ) -> bool {
519 self.is_enabled(severity) && state.should_log(now_ns, interval_ns)
520 }
521
522 /// Hand `record` to every registered sink, after the runtime
523 /// threshold check.
524 ///
525 /// Macros call this; user code should not.
526 pub fn dispatch(&self, record: &Record<'_>) {
527 if !self.is_enabled(record.severity) {
528 return;
529 }
530 dispatch_to_sinks(record);
531 }
532}
533
534// -----------------------------------------------------------------------------
535// Static intern table for `get_logger("name")`. Bounded; no alloc.
536// -----------------------------------------------------------------------------
537
538/// Maximum number of named loggers that can be registered via
539/// [`register_logger`]. Beyond this, [`get_logger`] returns
540/// [`DEFAULT_LOGGER`].
541pub const MAX_LOGGERS: usize = 32;
542
543/// Catch-all logger returned when the requested name is not
544/// registered (or the intern table is full).
545pub static DEFAULT_LOGGER: Logger = Logger::new("nros");
546
547mod intern {
548 use super::{AtomicPtr, Logger, MAX_LOGGERS, Ordering};
549
550 pub(super) struct InternTable {
551 slots: [AtomicPtr<Logger>; MAX_LOGGERS],
552 }
553
554 impl InternTable {
555 /// Every published logger, in slot order.
556 pub(super) fn iter(&self) -> impl Iterator<Item = &'static Logger> + '_ {
557 self.slots.iter().map_while(|slot| {
558 let ptr = slot.load(Ordering::Acquire);
559 // SAFETY: same publication invariant as `lookup`.
560 if ptr.is_null() {
561 None
562 } else {
563 Some(unsafe { &*ptr })
564 }
565 })
566 }
567
568 pub(super) const fn new() -> Self {
569 // `AtomicPtr::new` is `const` on both `core::sync::atomic`
570 // and `portable_atomic`, so we can initialise the array
571 // by repeating the call rather than naming a `const` —
572 // which clippy flags as interior-mutable.
573 #[allow(clippy::declare_interior_mutable_const)]
574 const NULL: AtomicPtr<Logger> = AtomicPtr::new(core::ptr::null_mut());
575 Self {
576 slots: [NULL; MAX_LOGGERS],
577 }
578 }
579
580 pub(super) fn lookup(&self, name: &str) -> Option<&'static Logger> {
581 for slot in &self.slots {
582 let ptr = slot.load(Ordering::Acquire);
583 if ptr.is_null() {
584 return None;
585 }
586 // SAFETY: pointer published via Release after the
587 // owner constructed a `'static Logger`. The Acquire
588 // load synchronizes.
589 let logger: &'static Logger = unsafe { &*ptr };
590 if logger.name() == name {
591 return Some(logger);
592 }
593 }
594 None
595 }
596
597 pub(super) fn insert(&self, logger: &'static Logger) -> Option<&'static Logger> {
598 if let Some(existing) = self.lookup(logger.name()) {
599 return Some(existing);
600 }
601 let ptr = logger as *const _ as *mut Logger;
602 for slot in &self.slots {
603 if slot
604 .compare_exchange(
605 core::ptr::null_mut(),
606 ptr,
607 Ordering::AcqRel,
608 Ordering::Acquire,
609 )
610 .is_ok()
611 {
612 return Some(logger);
613 }
614 let existing_ptr = slot.load(Ordering::Acquire);
615 if !existing_ptr.is_null() {
616 // SAFETY: same publication invariant as `lookup`.
617 let existing: &'static Logger = unsafe { &*existing_ptr };
618 if existing.name() == logger.name() {
619 return Some(existing);
620 }
621 }
622 }
623 None
624 }
625 }
626}
627
628static INTERN: intern::InternTable = intern::InternTable::new();
629
630/// The dotted ancestors of `name`, nearest first: `a.b.c` yields `a.b`, `a`.
631fn dotted_ancestors(name: &str) -> impl Iterator<Item = &str> {
632 let mut rest = name;
633 core::iter::from_fn(move || {
634 let cut = rest.rfind('.')?;
635 rest = &rest[..cut];
636 Some(rest)
637 })
638}
639
640/// RFC-0102 D2 — wire a logger that was JUST published into the hierarchy.
641///
642/// 1. Its parent is its nearest EXISTING dotted ancestor.
643/// 2. Every published logger whose current parent is a FARTHER ancestor than
644/// `new` (or which has none) and whose name has `new`'s as a dotted prefix
645/// is re-pointed at `new`. So the result does not depend on creation order:
646/// `a.b.c` created before `a.b` still resolves through `a.b` once it exists.
647///
648/// Runs on publication only — one pass over at most [`MAX_LOGGERS`] slots.
649/// Creation is expected at initialisation; two loggers created at the same
650/// instant from different threads can leave a link one level FARTHER than
651/// ideal, never one pointing at a non-ancestor, because every store here is of
652/// a logger whose name is a strict dotted prefix of the target's.
653fn link_published(new: &'static Logger) {
654 let ptr = new as *const Logger as *mut Logger;
655 if let Some(parent) = dotted_ancestors(new.name()).find_map(|a| INTERN.lookup(a)) {
656 new.parent
657 .store(parent as *const Logger as *mut Logger, Ordering::Release);
658 }
659 let n = new.name();
660 for other in INTERN.iter() {
661 let on = other.name();
662 let is_descendant =
663 on.len() > n.len() && on.starts_with(n) && on.as_bytes()[n.len()] == b'.';
664 if !is_descendant {
665 continue;
666 }
667 let closer = match other.parent() {
668 None => true,
669 Some(current) => current.name().len() < n.len(),
670 };
671 if closer {
672 other.parent.store(ptr, Ordering::Release);
673 }
674 }
675}
676
677/// Publish through the intern table and, when THIS call did the inserting,
678/// link the logger into the hierarchy. The one spelling both publication
679/// paths use.
680fn publish(logger: &'static Logger) -> Option<&'static Logger> {
681 let published = INTERN.insert(logger)?;
682 if core::ptr::eq(published, logger) {
683 link_published(published);
684 }
685 Some(published)
686}
687
688/// Publish `logger` under its name so subsequent `get_logger`
689/// calls with that name return THIS reference.
690///
691/// On name collision returns the pre-existing entry (the input
692/// `logger` is NOT inserted). On a full table returns
693/// [`DEFAULT_LOGGER`].
694pub fn register_logger(logger: &'static Logger) -> &'static Logger {
695 publish(logger).unwrap_or(&DEFAULT_LOGGER)
696}
697
698/// Look up a registered logger by name. Returns [`DEFAULT_LOGGER`]
699/// if none is registered (call [`register_logger`] for a `'static
700/// Logger` to publish one).
701///
702/// Total — every call returns a usable handle the macros can
703/// dispatch through.
704#[must_use]
705pub fn get_logger(name: &str) -> &'static Logger {
706 INTERN.lookup(name).unwrap_or(&DEFAULT_LOGGER)
707}
708
709/// Look up a logger by name, CREATING one if the name is new.
710///
711/// phase-417 W4.d. This is the shape `rclcpp::get_logger` / `rcutils`' logger
712/// lookup have, and the one a wrapper needs: [`get_logger`] answers
713/// [`DEFAULT_LOGGER`] for an unregistered name, so a C or C++ caller doing
714/// `set_level(get_logger("nav"), Debug)` over the lookup-only form would have
715/// moved the threshold of the catch-all logger that EVERY other unregistered
716/// name also resolves to. Same call, same types, different effect — precisely
717/// the compile-and-differ RFC-0089 forbids.
718///
719/// Storage is the bounded static arena in [`pool`], sized by the
720/// `NROS_LOG_DYNAMIC_LOGGERS` knob (RFC-0102 D5: env > board > 16). Returns `None` — never a logger under the
721/// wrong name — when:
722///
723/// * `name` is empty or longer than [`MAX_LOGGER_NAME_LEN`],
724/// * the name arena or the logger arena is full,
725/// * the [`MAX_LOGGERS`] intern table is full.
726///
727/// Callers decide what an exhausted arena means for them; `nros-c` reports it
728/// and falls back to [`DEFAULT_LOGGER`] loudly rather than silently.
729///
730/// Loggers created this way are never destroyed — the arena has no free list,
731/// which is what makes the returned `&'static Logger` honest on `no_std`.
732#[must_use]
733pub fn get_or_create_logger(name: &str) -> Option<&'static Logger> {
734 if let Some(existing) = INTERN.lookup(name) {
735 return Some(existing);
736 }
737 let interned = pool::intern_name(name)?;
738 let placed = pool::place(Logger::new(interned))?;
739 // Not `register_logger`: that answers `DEFAULT_LOGGER` on a full table,
740 // which would hand back a logger under the wrong name — the exact aliasing
741 // this function exists to avoid. On a lost race `insert` returns the
742 // winner and our slot is spent, which `pool::dynamic_loggers_in_use`
743 // reports.
744 publish(placed)
745}
746
747/// Why [`Logger::create_child`] made no child — RFC-0102 D1.
748///
749/// rclrs's `create_child` is fallible too (`Result<Logger, RclrsError>`), so a
750/// Rust caller chooses its own fallback (`parent.create_child("x").unwrap_or(parent)`).
751/// The C and C++ surfaces, whose upstream shapes cannot report failure, fall
752/// back to the parent automatically (RFC-0102 D3).
753#[derive(Debug, Clone, Copy, PartialEq, Eq)]
754pub enum ChildError {
755 /// The child's full name is longer than [`MAX_LOGGER_NAME_LEN`] bytes.
756 NameTooLong {
757 /// Bytes the full name would have.
758 len: usize,
759 /// [`MAX_LOGGER_NAME_LEN`].
760 max: usize,
761 },
762 /// The full name would be empty: an empty `child_name` under the
763 /// catch-all, whose children are top-level names.
764 EmptyName,
765 /// The runtime-logger arena, its name bytes, or the intern table is full.
766 ArenaFull,
767}
768
769impl core::fmt::Display for ChildError {
770 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
771 match self {
772 Self::NameTooLong { len, max } => {
773 write!(
774 f,
775 "child logger name is {len} bytes, over the {max}-byte limit"
776 )
777 }
778 Self::EmptyName => f.write_str("child logger name is empty"),
779 Self::ArenaFull => f.write_str(
780 "no runtime logger slot left (arena, name bytes, or intern table is full)",
781 ),
782 }
783 }
784}
785
786impl Logger {
787 /// A logger that is a descendant of this one — rclrs's `create_child`,
788 /// rclcpp's `get_child` (RFC-0102 D1).
789 ///
790 /// Named `"{self}.{child_name}"`, except that the catch-all
791 /// ([`DEFAULT_LOGGER`]) has top-level children: its child is named
792 /// `child_name`, as rclrs does for its empty-named default logger.
793 ///
794 /// A child with no level of its own filters at its nearest ancestor's
795 /// (rcutils's rule, RFC-0102 D2), so `parent.set_level(Debug)` reaches it.
796 /// Idempotent: the same name returns the same logger, so a call inside a
797 /// log statement costs one intern-table lookup.
798 ///
799 /// Takes `&'static self` because the child links to its parent by
800 /// reference. `self` is published to the intern table first if it was
801 /// not, so a `static` parent nobody registered still passes its level on.
802 ///
803 /// # Errors
804 /// [`ChildError`] when the full name is over [`MAX_LOGGER_NAME_LEN`] or
805 /// empty, or no slot is left. The name is built in a stack buffer; nothing
806 /// is allocated.
807 pub fn create_child(
808 &'static self,
809 child_name: impl core::borrow::Borrow<str>,
810 ) -> Result<&'static Logger, ChildError> {
811 let child = child_name.borrow();
812 let top = core::ptr::eq(self, &DEFAULT_LOGGER);
813 let len = if top {
814 child.len()
815 } else {
816 self.name.len() + 1 + child.len()
817 };
818 if len == 0 {
819 return Err(ChildError::EmptyName);
820 }
821 if len > MAX_LOGGER_NAME_LEN {
822 return Err(ChildError::NameTooLong {
823 len,
824 max: MAX_LOGGER_NAME_LEN,
825 });
826 }
827 if !top && INTERN.lookup(self.name).is_none() {
828 // A full table leaves the child unlinked: it then filters at the
829 // process default, which is what an unregistered parent meant
830 // before RFC-0102 too.
831 let _ = publish(self);
832 }
833 let mut buf = [0u8; MAX_LOGGER_NAME_LEN];
834 let mut at = 0;
835 if !top {
836 buf[..self.name.len()].copy_from_slice(self.name.as_bytes());
837 at = self.name.len();
838 buf[at] = b'.';
839 at += 1;
840 }
841 buf[at..len].copy_from_slice(child.as_bytes());
842 // SAFETY: two `&str`s joined by an ASCII `.` are valid UTF-8, and the
843 // cut is at their end.
844 let name = unsafe { core::str::from_utf8_unchecked(&buf[..len]) };
845 get_or_create_logger(name).ok_or(ChildError::ArenaFull)
846 }
847}
848
849/// The published logger called `name`, if any — the lookup the `/rosout`
850/// sink filters by (RFC-0102 D4), which sees a record's name, not its logger.
851#[cfg(feature = "rosout")]
852pub(crate) fn find_published(name: &str) -> Option<&'static Logger> {
853 INTERN.lookup(name)
854}
855
856/// [`resolve_logger`] for a NODE's own logger, marking it as one — the one
857/// spelling the node-shaped accessors use (`Node::logger`,
858/// `rclc_node_get_logger`, the C++ node's), so the `/rosout` sink can publish
859/// what upstream publishes (RFC-0102 D4). The catch-all is never marked: a node
860/// whose name fell back to it would otherwise publish every unnamed logger.
861#[must_use]
862pub fn resolve_node_logger(name: &str) -> &'static Logger {
863 let logger = resolve_logger(name);
864 if !core::ptr::eq(logger, &DEFAULT_LOGGER) {
865 logger.node.store(true, Ordering::Relaxed);
866 }
867 logger
868}
869
870/// Resolve `name` to a logger — the TOTAL form of [`get_or_create_logger`],
871/// and the one spelling every node-shaped accessor uses.
872///
873/// phase-427 W5. Four accessors answer "which logger is this node's?" —
874/// `nros_node::executor::Node::logger`, `rclc_node_get_logger`,
875/// `nros_cpp_node_get_logger` and `rclc_log_get_logger`. All four had the same
876/// job and, before this, three of them called [`get_logger`], which is a
877/// LOOKUP: a node whose name nobody `register_logger`ed resolved to
878/// [`DEFAULT_LOGGER`]. Two nodes in one image therefore emitted every record
879/// under the same name, `"nros"` — the sentinel W5 replaced, arrived at by a
880/// different route.
881///
882/// This creates instead, and falls back to [`DEFAULT_LOGGER`] only when it
883/// cannot — an empty or over-long name, or an exhausted arena — and says so at
884/// WARN when it does, because the catch-all's threshold is SHARED and a silent
885/// alias makes `set_level` on the result move every other unnamed logger's bar
886/// with it. The report is ONE per process: a C++ `NROS_LOG_*` call site asks
887/// `node.get_logger()` on every line, so a per-call report would be a flood.
888///
889/// Lookup comes first, so an image that pre-registered a `'static Logger` under
890/// the node's name still gets that exact reference and its threshold.
891#[must_use]
892pub fn resolve_logger(name: &str) -> &'static Logger {
893 if let Some(logger) = get_or_create_logger(name) {
894 return logger;
895 }
896 // ONE report per process. This is reached from `Node::get_logger()`, which
897 // a C++ call site invokes inside every `NROS_LOG_*` macro — so a node whose
898 // name is longer than `MAX_LOGGER_NAME_LEN` (node names may be 64 bytes,
899 // logger names 48) would otherwise emit a warning per log line, and the
900 // diagnostic would be the flood. The first failure is the informative one:
901 // the arena only ever gets fuller.
902 static REPORTED: AtomicU8 = AtomicU8::new(0);
903 if REPORTED.swap(1, Ordering::Relaxed) != 0 {
904 return &DEFAULT_LOGGER;
905 }
906 let (used, total) = dynamic_logger_name_arena();
907 crate::log_warn!(
908 &DEFAULT_LOGGER,
909 "resolve_logger(\"{name}\"): no logger created — {} of {} slots and {used} of {total} \
910 name bytes are spent, or the name is over {} bytes. Returning the catch-all logger, \
911 whose threshold is SHARED: raise NROS_LOG_DYNAMIC_LOGGERS rather than calling \
912 set_level on this handle. Reported once per process.",
913 dynamic_loggers_in_use(),
914 dynamic_logger_capacity(),
915 MAX_LOGGER_NAME_LEN,
916 );
917 &DEFAULT_LOGGER
918}
919
920// -----------------------------------------------------------------------------
921// Sink list. Set once at `init`; read every dispatch.
922// -----------------------------------------------------------------------------
923
924static SINKS_PTR: AtomicPtr<&'static [&'static dyn LogSink]> =
925 AtomicPtr::new(core::ptr::null_mut());
926
927// issue 0710 — `init_default()` was here. It named a default this crate can no
928// longer name: the platform sink moved to `nros_platform_cffi::log`, where the
929// ABI it speaks is a dependency rather than a feature. Call
930// `nros_platform_cffi::log::init_default()`, or `init()` with your own sinks.
931
932/// Install the global sink list.
933///
934/// MUST be called at app startup BEFORE any record-emitting macro
935/// runs (otherwise the dispatch is a no-op — records are silently
936/// dropped). Calling `init` more than once swaps the list
937/// atomically; the previous pointer is leaked (intentional: the
938/// read path is lock-free so we can't safely free).
939///
940/// The sinks themselves must outlive the program (`'static`).
941pub fn init(sinks: &'static [&'static dyn LogSink]) {
942 // Indirect through a small `'static` cell so the read path
943 // dereferences a fat-pointer-sized slot rather than reading
944 // a wide pointer atomically.
945 #[cfg(feature = "alloc")]
946 {
947 let boxed: alloc::boxed::Box<&'static [&'static dyn LogSink]> =
948 alloc::boxed::Box::new(sinks);
949 let ptr = alloc::boxed::Box::into_raw(boxed);
950 SINKS_PTR.store(ptr, Ordering::Release);
951 }
952 #[cfg(not(feature = "alloc"))]
953 {
954 static CELL: SinkSlot = SinkSlot::new();
955 CELL.store(sinks);
956 SINKS_PTR.store(CELL.as_ptr(), Ordering::Release);
957 }
958 // AFTER publishing, so a record raised during the drain reaches `sinks`
959 // directly rather than joining a ring nobody will drain again.
960 early::drain(sinks);
961 let lost = early::overflowed();
962 if lost > 0 {
963 // Reported through the sinks just installed, because the alternative is
964 // a silent hole exactly where the boot story is (`nros-log` cannot know
965 // what those records said, but it does know how many there were).
966 let logger = Logger::new("nros_log");
967 crate::log_warn!(
968 &logger,
969 "{lost} record(s) raised before `init` did not fit the early ring \
970 (see `nros_log::early`; raise `early-records-<N>`)"
971 );
972 }
973}
974
975#[cfg(not(feature = "alloc"))]
976struct SinkSlot {
977 inner: core::cell::UnsafeCell<Option<&'static [&'static dyn LogSink]>>,
978}
979
980#[cfg(not(feature = "alloc"))]
981// SAFETY: only written from `init`, which the user contracts to call
982// once at startup before any concurrent reader exists.
983unsafe impl Sync for SinkSlot {}
984
985#[cfg(not(feature = "alloc"))]
986impl SinkSlot {
987 const fn new() -> Self {
988 Self {
989 inner: core::cell::UnsafeCell::new(None),
990 }
991 }
992 fn store(&self, sinks: &'static [&'static dyn LogSink]) {
993 // SAFETY: see Sync note above.
994 unsafe {
995 *self.inner.get() = Some(sinks);
996 }
997 }
998 fn as_ptr(&self) -> *mut &'static [&'static dyn LogSink] {
999 self.inner.get().cast()
1000 }
1001}
1002
1003// -----------------------------------------------------------------------------
1004// Appendable sink registry (phase-417 W4.d).
1005//
1006// `init` REPLACES the list, which is right for the board — it names, once, the
1007// whole delivery it chose. It is the wrong verb for a consumer: C and C++ had
1008// no way to install a sink at all (`rclc_log_init(void)` takes no arguments
1009// and hardcodes the platform default), and a library that wants to tee records
1010// to /rosout cannot call `init` without discarding whatever the board
1011// installed.
1012//
1013// So `add_sink` APPENDS, and dispatch walks both lists. The two are kept
1014// separate rather than merged into one growable list because they have
1015// different owners and different lifetimes-of-decision: `init` may be called
1016// again and swap the board's choice wholesale, and doing so must not silently
1017// unregister a consumer's sink.
1018// -----------------------------------------------------------------------------
1019
1020/// How many sinks [`add_sink`] can append on top of the [`init`] list.
1021///
1022/// Small on purpose: the appendable list is for consumers that TEE (a /rosout
1023/// bridge, a test collector), not for composing a delivery — a board composing
1024/// its own delivery passes the whole slice to [`init`].
1025pub const MAX_ADDED_SINKS: usize = 4;
1026
1027struct AddedSinks {
1028 slots: core::cell::UnsafeCell<[Option<&'static dyn LogSink>; MAX_ADDED_SINKS]>,
1029 /// Number of slots fully written. `Release` on publish, `Acquire` on read.
1030 len: AtomicUsize,
1031}
1032
1033// SAFETY: writes happen only under `ADD_LOCK` and only to the slot at index
1034// `len`, which no reader looks at until the `Release` store to `len` that
1035// follows the write is observed by an `Acquire` load. A slot is written once
1036// and never rewritten.
1037unsafe impl Sync for AddedSinks {}
1038
1039static ADDED: AddedSinks = AddedSinks {
1040 slots: core::cell::UnsafeCell::new([None; MAX_ADDED_SINKS]),
1041 len: AtomicUsize::new(0),
1042};
1043
1044/// Serialises `add_sink` against itself.
1045///
1046/// A spin lock, held for two stores with no call inside it, is admissible here
1047/// where the in-order-publish alternative is not: that one has a thread
1048/// waiting on ANOTHER thread's progress, which on a single-core RTOS with
1049/// priorities is a deadlock rather than a delay. Do not call `add_sink` from
1050/// an ISR.
1051static ADD_LOCK: AtomicBool = AtomicBool::new(false);
1052
1053/// Append `sink` to the sinks every record is delivered to.
1054///
1055/// Returns `false` when [`MAX_ADDED_SINKS`] are already registered — the sink
1056/// is NOT installed, and the caller is expected to say so rather than carry on
1057/// as if it were.
1058///
1059/// Records raised before any sink existed are replayed into `sink` here, the
1060/// same way [`init`] replays them, so the first sink to arrive still sees the
1061/// boot story regardless of which of the two calls installed it.
1062pub fn add_sink(sink: &'static dyn LogSink) -> bool {
1063 while ADD_LOCK
1064 .compare_exchange(false, true, Ordering::Acquire, Ordering::Acquire)
1065 .is_err()
1066 {
1067 core::hint::spin_loop();
1068 }
1069 let idx = ADDED.len.load(Ordering::Relaxed);
1070 let installed = if idx < MAX_ADDED_SINKS {
1071 // SAFETY: `ADD_LOCK` makes this the only writer, `idx` is beyond what
1072 // any reader may look at until the `Release` store below publishes it,
1073 // and the slot is never written again.
1074 unsafe {
1075 ADDED
1076 .slots
1077 .get()
1078 .cast::<Option<&'static dyn LogSink>>()
1079 .add(idx)
1080 .write(Some(sink));
1081 }
1082 ADDED.len.store(idx + 1, Ordering::Release);
1083 true
1084 } else {
1085 false
1086 };
1087 ADD_LOCK.store(false, Ordering::Release);
1088
1089 if installed && SINKS_PTR.load(Ordering::Acquire).is_null() && idx == 0 {
1090 // First delivery target in the program, and `init` has not run: the
1091 // held records are ours to replay. AFTER publishing, for the reason
1092 // `init` states — a record raised during the drain reaches the sink
1093 // directly rather than joining a ring nobody will drain again.
1094 early::drain_with(&mut |record| sink.log(record));
1095 }
1096 installed
1097}
1098
1099/// How many sinks [`add_sink`] has installed.
1100#[must_use]
1101pub fn added_sink_count() -> usize {
1102 ADDED.len.load(Ordering::Acquire)
1103}
1104
1105/// Hand `record` to every sink installed by [`add_sink`]. Returns how many
1106/// saw it.
1107fn deliver_to_added(record: &Record<'_>) -> usize {
1108 let len = ADDED.len.load(Ordering::Acquire);
1109 let base = ADDED.slots.get().cast::<Option<&'static dyn LogSink>>();
1110 for idx in 0..len {
1111 // SAFETY: `idx < len`, and the `Acquire` load above pairs with the
1112 // `Release` store in `add_sink` that published slot `idx`. Slots below
1113 // `len` are immutable for the rest of the program.
1114 if let Some(sink) = unsafe { base.add(idx).read() } {
1115 sink.log(record);
1116 }
1117 }
1118 len
1119}
1120
1121/// Whether [`__timestamp_ns`] can answer with a real clock.
1122///
1123/// `false` means every `Record::timestamp_ns` is `0` and anything DERIVED from
1124/// the timestamp — the throttle windows above all of them — has no time base:
1125/// a throttled site then emits its FIRST record and nothing after it (a clock
1126/// that never moves is a window that never elapses — issue 1152). Exposed so a
1127/// wrapper can say so out loud instead of degrading quietly: without this, a
1128/// throttled C call site looks identical whether it is rate-limiting or
1129/// permanently shut.
1130#[must_use]
1131pub const fn timestamp_available() -> bool {
1132 cfg!(nros_log_clock)
1133}
1134
1135/// Current monotonic time for `Record::timestamp_ns` (issue #503).
1136///
1137/// With the platform clock (`cfg(nros_log_clock)`: the lane's platform
1138/// declares `[capabilities] clock = true`, or the `platform-clock` feature
1139/// pulled a port in — issue 1037, see `build.rs`) this reads
1140/// `nros_platform_clock_ns` — the universal per-platform export the
1141/// executor's timer accounting already links. Without it it returns `0`
1142/// ("unavailable") and imposes no link-time requirement.
1143///
1144/// Public because the emission macros expand it in user crates; not
1145/// part of the supported API surface.
1146#[doc(hidden)]
1147#[must_use]
1148pub fn __timestamp_ns() -> u64 {
1149 #[cfg(nros_log_clock)]
1150 {
1151 unsafe extern "C" {
1152 fn nros_platform_clock_ns() -> u64;
1153 }
1154 // SAFETY: bare query of the platform's monotonic us counter;
1155 // the symbol comes from whichever `nros-platform-<rtos>` port
1156 // linked the binary (the contract `PlatformSink ->
1157 // nros_platform_log_write` already relies on).
1158 unsafe { nros_platform_clock_ns() }
1159 }
1160 #[cfg(not(nros_log_clock))]
1161 {
1162 0
1163 }
1164}
1165
1166fn dispatch_to_sinks(record: &Record<'_>) {
1167 if recursion_guard_check_and_set() {
1168 return;
1169 }
1170 // phase-417 W4.d — the appendable list is walked whether or not `init`
1171 // ran, and a record with at least one destination is NOT held.
1172 let added = deliver_to_added(record);
1173 let ptr = SINKS_PTR.load(Ordering::Acquire);
1174 if ptr.is_null() {
1175 if added > 0 {
1176 recursion_guard_clear();
1177 return;
1178 }
1179 // Nothing installed yet — HOLD the record; `init` replays it into
1180 // whatever sinks the board chooses. See `early` for why this crate
1181 // does not reach for the platform sink itself: doing that (issue 0710)
1182 // put `nros_platform_log_write` on a path every binary executes, which
1183 // turned a pluggable delivery into a link-time requirement that no
1184 // Cargo feature can undo under workspace feature unification.
1185 //
1186 // It is also a better answer to issue 0708 than installing a default
1187 // was: the early records land in the sink the board picked, not in the
1188 // one dispatch guessed before the board had spoken.
1189 early::hold(record);
1190 recursion_guard_clear();
1191 return;
1192 }
1193 {
1194 // SAFETY: `init` published a valid `'static` slice reference.
1195 let sinks: &'static [&'static dyn LogSink] = unsafe { *ptr };
1196 for sink in sinks {
1197 sink.log(record);
1198 }
1199 }
1200 recursion_guard_clear();
1201}
1202
1203/// Flush every registered sink, appended ones included.
1204pub fn flush() {
1205 let len = ADDED.len.load(Ordering::Acquire);
1206 let base = ADDED.slots.get().cast::<Option<&'static dyn LogSink>>();
1207 for idx in 0..len {
1208 // SAFETY: same publication invariant as `deliver_to_added`.
1209 if let Some(sink) = unsafe { base.add(idx).read() } {
1210 sink.flush();
1211 }
1212 }
1213 let ptr = SINKS_PTR.load(Ordering::Acquire);
1214 if ptr.is_null() {
1215 return;
1216 }
1217 // SAFETY: same invariant as `dispatch_to_sinks`.
1218 let sinks: &'static [&'static dyn LogSink] = unsafe { *ptr };
1219 for sink in sinks {
1220 sink.flush();
1221 }
1222}
1223
1224// -----------------------------------------------------------------------------
1225// Recursion guard — process-global single AtomicBool.
1226//
1227// Granularity is intentionally coarse (process-wide, not per-thread).
1228// The guard exists to break a sink that triggers log() during write
1229// — not to serialize concurrent loggers across threads. A thread
1230// re-entering through its own sink loses its other in-flight
1231// sinks for that call; a different thread logging concurrently is
1232// also short-circuited momentarily. This is acceptable: the alt is
1233// per-thread storage which doesn't exist uniformly across our
1234// `no_std` targets (`thread_local!` requires `std`).
1235// -----------------------------------------------------------------------------
1236
1237static RECURSION_GUARD: AtomicBool = AtomicBool::new(false);
1238
1239fn recursion_guard_check_and_set() -> bool {
1240 RECURSION_GUARD
1241 .compare_exchange(false, true, Ordering::Acquire, Ordering::Acquire)
1242 .is_err()
1243}
1244
1245fn recursion_guard_clear() {
1246 RECURSION_GUARD.store(false, Ordering::Release);
1247}
1248
1249// -----------------------------------------------------------------------------
1250// Tests (host-only).
1251// -----------------------------------------------------------------------------
1252
1253#[cfg(test)]
1254mod tests {
1255 use super::*;
1256
1257 #[test]
1258 fn severity_round_trips_through_u8() {
1259 for s in [
1260 Severity::Trace,
1261 Severity::Debug,
1262 Severity::Info,
1263 Severity::Warn,
1264 Severity::Error,
1265 Severity::Fatal,
1266 ] {
1267 assert_eq!(severity_from_u8(s.as_u8()), Some(s));
1268 }
1269 assert_eq!(severity_from_u8(99), None);
1270 }
1271
1272 #[test]
1273 fn logger_runtime_threshold_filters_below() {
1274 let logger = Logger::with_level("test_thresh", Severity::Warn);
1275 assert!(!logger.is_enabled(Severity::Info));
1276 assert!(logger.is_enabled(Severity::Warn));
1277 assert!(logger.is_enabled(Severity::Error));
1278 logger.set_level(Severity::Debug);
1279 assert!(logger.is_enabled(Severity::Info));
1280 }
1281
1282 /// phase-467, ledger rows `c:log_severity_t` + `rust:Logger::set_default_level`.
1283 ///
1284 /// ONE test rather than five, deliberately: the process default is
1285 /// process-wide state and `cargo test` runs this crate's tests as threads
1286 /// in one process, so a second test that moved it would race this one and
1287 /// the flake would read as a logic bug. It restores the default before it
1288 /// returns for the same reason.
1289 #[test]
1290 fn a_logger_with_no_level_of_its_own_follows_the_process_default() {
1291 assert_eq!(Logger::default_level(), Severity::Info, "starting default");
1292
1293 let unset = Logger::new("test_unset_follows_default");
1294 let stated = Logger::with_level("test_stated_keeps_its_own", Severity::Warn);
1295
1296 // Unset logger, untouched default: Info, exactly as before phase-467.
1297 assert_eq!(unset.level(), Severity::Info);
1298 assert!(!unset.is_enabled(Severity::Debug));
1299 assert!(unset.is_enabled(Severity::Info));
1300
1301 // Moving the default moves it RETROACTIVELY — the logger already
1302 // existed. This is what a re-export of `DEFAULT_LOGGER.set_level`
1303 // could not do.
1304 Logger::set_default_level(Severity::Debug);
1305 assert_eq!(Logger::default_level(), Severity::Debug);
1306 assert_eq!(unset.level(), Severity::Debug);
1307 assert!(unset.is_enabled(Severity::Debug));
1308 assert!(!unset.is_enabled(Severity::Trace));
1309
1310 // ...and does NOT override a logger that states its own level. If it
1311 // did, `with_level(.., Info)` and `new(..)` would be the same thing,
1312 // which is the distinction the sentinel exists to keep.
1313 assert_eq!(stated.level(), Severity::Warn);
1314 assert!(!stated.is_enabled(Severity::Info));
1315
1316 // Stating a level on the unset logger detaches it.
1317 unset.set_level(Severity::Error);
1318 Logger::set_default_level(Severity::Trace);
1319 assert_eq!(unset.level(), Severity::Error);
1320 assert!(!unset.is_enabled(Severity::Warn));
1321
1322 // ...and unsetting it re-attaches it, to the CURRENT default.
1323 unset.unset_level();
1324 assert_eq!(unset.level(), Severity::Trace);
1325 assert!(unset.is_enabled(Severity::Trace));
1326
1327 // The sentinel is outside `Severity`, in the direction that can only
1328 // ever SUPPRESS a record: `severity_from_u8` refuses it, and it is
1329 // above `Fatal`, not below `Trace` where rcutils's own `UNSET` sits.
1330 assert_eq!(severity_from_u8(LEVEL_UNSET), None);
1331 assert!(LEVEL_UNSET > Severity::Fatal.as_u8());
1332
1333 Logger::set_default_level(Severity::Info);
1334 }
1335
1336 #[test]
1337 fn unregistered_get_logger_returns_default() {
1338 let l = get_logger("definitely-not-registered-99");
1339 assert_eq!(l.name(), DEFAULT_LOGGER.name());
1340 }
1341
1342 #[test]
1343 fn registered_logger_round_trips_through_intern_table() {
1344 static LOGGER: Logger = Logger::new("test_intern_round_trip");
1345 let published = register_logger(&LOGGER);
1346 assert_eq!(published.name(), LOGGER.name());
1347 let looked_up = get_logger("test_intern_round_trip");
1348 assert!(core::ptr::eq(published, looked_up));
1349 }
1350
1351 #[test]
1352 fn compile_time_ceiling_matches_the_resolved_knob() {
1353 // `config::MAX_LEVEL` is `NROS_LOG_MAX_LEVEL` as `build.rs` resolved
1354 // it (builtin 0 = trace; 6 = off).
1355 let ceiling = severity_from_u8(config::MAX_LEVEL);
1356 for severity in [
1357 Severity::Trace,
1358 Severity::Debug,
1359 Severity::Info,
1360 Severity::Warn,
1361 Severity::Error,
1362 Severity::Fatal,
1363 ] {
1364 let enabled = ceiling.is_some_and(|c| severity >= c);
1365 assert_eq!(severity_enabled_at_compile_time(severity), enabled);
1366 // The ceiling bounds the runtime level: below it, no logger level
1367 // can switch a severity back on.
1368 static PROBE: Logger = Logger::with_level("ceiling_probe", Severity::Trace);
1369 assert_eq!(PROBE.is_enabled(severity), enabled);
1370 }
1371 }
1372}