Skip to main content

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}