rivet/log.rs
1//! Deferred-formatting logging: [`log!`](crate::log!) is safe to call from
2//! ISR context (it does no formatting, no allocation, and never blocks —
3//! it just pushes a `{level, task_id, timestamp, message}` frame into a
4//! ring buffer), and a drain task formats and writes frames to the
5//! console at its own pace, off the hot path.
6//!
7//! # Scope note (plan.md Phase 8, extended by Phase 16)
8//!
9//! The old plan (§6.5) called for interning format strings into a
10//! `.rivet_log_fmt` linker section, storing only a small integer index per
11//! frame, and decoding on the host from the ELF's debug info (a
12//! `rivet-decode` crate). This module takes a simpler route that still
13//! delivers the properties that actually matter (ISR-safe, O(1) in the
14//! hot path, deferred formatting, lock-free ring buffer, dropped-frame
15//! accounting): a frame stores the message as a plain `&'static str`
16//! pointer + length, plus (Phase 16) up to two [`LogArg`] values —
17//! **not** a full `format_args!`-style template (that needs the
18//! interned-format-string + host-decoder machinery the old plan
19//! described, still not attempted here). `log!("x={}", x)` covers the
20//! large majority of real call sites, which log one or two values
21//! alongside a fixed message; `write_frame` substitutes each `{}` in
22//! `msg` with the corresponding argument, formatted at drain time (off
23//! the hot path, same as everything else here).
24//!
25//! The ring buffer is Rivet's own SPSC [`crate::sync::Channel`] — but
26//! logging is inherently **multi**-producer (any task or ISR might log,
27//! on any hart), so every producer path goes through
28//! [`crate::critical::enter`] to serialize pushes into a single logical
29//! producer. Since plan.md Phase 19, `critical::enter` is a genuine
30//! cross-hart lock (not just a local interrupt mask), so this holds under
31//! real SMP too, not only the single-hart case.
32
33use crate::sync::{Channel, Once, Receiver, Sender};
34use crate::sync::atomic::{AtomicU32, Ordering};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Level {
38 Trace,
39 Debug,
40 Info,
41 Warn,
42 Error,
43}
44
45impl Level {
46 fn as_str(self) -> &'static str {
47 match self {
48 Level::Trace => "TRACE",
49 Level::Debug => "DEBUG",
50 Level::Info => "INFO",
51 Level::Warn => "WARN",
52 Level::Error => "ERROR",
53 }
54 }
55}
56
57/// A single interpolated argument (plan.md Phase 16): a small closed set
58/// covering the large majority of real log call sites, not a general
59/// `Display`/`Debug` payload (which would need real formatting work done
60/// eagerly, defeating the point of deferring it to drain time).
61#[derive(Clone, Copy)]
62pub enum LogArg {
63 /// No argument in this slot (fewer than 2 given to `log!`).
64 None,
65 U32(u32),
66 I32(i32),
67 F32(f32),
68 Str(&'static str),
69}
70
71impl From<u32> for LogArg {
72 fn from(v: u32) -> Self {
73 LogArg::U32(v)
74 }
75}
76impl From<i32> for LogArg {
77 fn from(v: i32) -> Self {
78 LogArg::I32(v)
79 }
80}
81impl From<f32> for LogArg {
82 fn from(v: f32) -> Self {
83 LogArg::F32(v)
84 }
85}
86impl From<&'static str> for LogArg {
87 fn from(v: &'static str) -> Self {
88 LogArg::Str(v)
89 }
90}
91
92#[derive(Clone, Copy)]
93pub struct LogFrame {
94 pub level: Level,
95 /// Preemptive task id, or `None` if logged from a context with no
96 /// current task (e.g. before the scheduler starts).
97 pub task_id: Option<u16>,
98 pub timestamp_us: u32,
99 pub msg: &'static str,
100 /// Up to two interpolated arguments, substituted in order for each
101 /// `{}` in `msg` at drain time. `LogArg::None` in a slot the message
102 /// doesn't reference is simply unused.
103 pub args: [LogArg; 2],
104}
105
106/// Ring capacity. Deliberately not wired into `RIVET_*` build-time config
107/// (plan.md §4.1) yet — a fixed default is the honest starting point for a
108/// feature this new; making it configurable is a small follow-up once
109/// there's a real workload to size it against.
110const CAPACITY: usize = 16;
111
112#[cfg(not(loom))]
113static CHANNEL: Channel<LogFrame, CAPACITY> = Channel::new();
114#[cfg(loom)]
115loom::lazy_static! {
116 static ref CHANNEL: Channel<LogFrame, CAPACITY> = Channel::new();
117}
118
119#[cfg(not(loom))]
120static SENDER: Once<Sender<'static, LogFrame, CAPACITY>> = Once::new();
121#[cfg(loom)]
122loom::lazy_static! {
123 static ref SENDER: Once<Sender<'static, LogFrame, CAPACITY>> = Once::new();
124}
125
126#[cfg(not(loom))]
127static RECEIVER: Once<Receiver<'static, LogFrame, CAPACITY>> = Once::new();
128#[cfg(loom)]
129loom::lazy_static! {
130 static ref RECEIVER: Once<Receiver<'static, LogFrame, CAPACITY>> = Once::new();
131}
132#[cfg(not(loom))]
133static DROPPED: AtomicU32 = AtomicU32::new(0);
134#[cfg(loom)]
135loom::lazy_static! {
136 static ref DROPPED: AtomicU32 = AtomicU32::new(0);
137}
138
139/// Called once from [`crate::init`]. Splitting the channel here (rather
140/// than lazily on first use) means the first `log!` call anywhere is
141/// never the one paying for initialization, and keeps `push`'s hot path
142/// to just a critical-section-guarded `try_send`.
143pub(crate) fn init() {
144 if let Some((tx, rx)) = CHANNEL.split() {
145 let _ = SENDER.set(tx);
146 let _ = RECEIVER.set(rx);
147 }
148}
149
150/// Push a frame. ISR-safe: no allocation, no unbounded loops, never
151/// blocks — a full ring drops the frame and counts it (see
152/// [`dropped_frames`]) rather than backing up whatever called this.
153#[doc(hidden)]
154pub fn push(level: Level, msg: &'static str, arg0: LogArg, arg1: LogArg) {
155 let task_id = crate::preempt::sched::current().map(|id| id as u16);
156 let timestamp_us = crate::port::board::now_us() as u32;
157 let frame = LogFrame {
158 level,
159 task_id,
160 timestamp_us,
161 msg,
162 args: [arg0, arg1],
163 };
164 let sent = match SENDER.get() {
165 // SAFETY-relevant, not memory-safety: `try_send` requires a
166 // single logical producer; the critical section serializes
167 // however many concurrent callers (tasks and/or ISRs) there are
168 // into one.
169 Some(tx) => crate::critical::enter(|| tx.try_send(frame)).is_ok(),
170 None => false,
171 };
172 if !sent {
173 DROPPED.fetch_add(1, Ordering::Relaxed);
174 }
175}
176
177/// Number of frames dropped so far because the ring was full (or logging
178/// hadn't been initialized yet — i.e. called before [`crate::init`]).
179pub fn dropped_frames() -> usize {
180 DROPPED.load(Ordering::Relaxed) as usize
181}
182
183fn write_frame(frame: &LogFrame) {
184 crate::console::write_str("[");
185 crate::console::write_str(frame.level.as_str());
186 crate::console::write_str("] t=");
187 write_dec(frame.timestamp_us as usize);
188 if let Some(id) = frame.task_id {
189 crate::console::write_str(" task=");
190 write_dec(id as usize);
191 }
192 crate::console::write_str(" ");
193 write_interpolated(frame.msg, &frame.args);
194 crate::console::write_str("\n");
195}
196
197/// Write `msg`, substituting each `{}` (in order) with the corresponding
198/// entry of `args`. Extra `{}` beyond the two argument slots are written
199/// through literally, since there's nothing to fill them with — no
200/// silent truncation of the message.
201fn write_interpolated(msg: &str, args: &[LogArg; 2]) {
202 let mut rest = msg;
203 let mut arg_idx = 0usize;
204 while let Some(pos) = rest.find("{}") {
205 crate::console::write_str(&rest[..pos]);
206 match args.get(arg_idx) {
207 Some(LogArg::None) | None => crate::console::write_str("{}"),
208 Some(arg) => write_arg(arg),
209 }
210 arg_idx += 1;
211 rest = &rest[pos + 2..];
212 }
213 crate::console::write_str(rest);
214}
215
216fn write_arg(arg: &LogArg) {
217 match *arg {
218 LogArg::None => {}
219 LogArg::U32(v) => crate::console::_print(core::format_args!("{v}")),
220 LogArg::I32(v) => crate::console::_print(core::format_args!("{v}")),
221 LogArg::F32(v) => crate::console::_print(core::format_args!("{v}")),
222 LogArg::Str(s) => crate::console::write_str(s),
223 }
224}
225
226fn write_dec(mut n: usize) {
227 if n == 0 {
228 crate::console::write_str("0");
229 return;
230 }
231 let mut digits = [0u8; 20];
232 let mut i = 0;
233 while n > 0 {
234 digits[i] = b'0' + (n % 10) as u8;
235 n /= 10;
236 i += 1;
237 }
238 let mut buf = [0u8; 20];
239 for j in 0..i {
240 buf[j] = digits[i - 1 - j];
241 }
242 if let Ok(s) = core::str::from_utf8(&buf[..i]) {
243 crate::console::write_str(s);
244 }
245}
246
247/// Drain and format one pending frame. Returns `false` if the ring was
248/// empty. Call this in a loop from a low-priority task to flush the log
249/// (see [`drain_forever`] for a ready-made one).
250pub fn drain_one() -> bool {
251 match RECEIVER.get().and_then(|rx| rx.try_recv()) {
252 Some(frame) => {
253 write_frame(&frame);
254 true
255 }
256 None => false,
257 }
258}
259
260/// A ready-made drain loop: `.await`s new frames and writes them to the
261/// console as they arrive. Spawn this as a low-priority `#[rivet::task]`
262/// if you want logging without writing your own drain loop.
263///
264/// ```ignore
265/// #[rivet::task(priority = 0)]
266/// async fn log_drain() {
267/// rivet::log::drain_forever().await;
268/// }
269/// ```
270pub async fn drain_forever() -> ! {
271 let rx = loop {
272 if let Some(rx) = RECEIVER.get() {
273 break rx;
274 }
275 // Logging hasn't been initialized yet (called before rivet::init)
276 // — extremely unlikely given normal boot order, but don't spin
277 // hot if it happens.
278 crate::time::Sleep::<1000>::new().await;
279 };
280 loop {
281 let frame = rx.recv().await;
282 write_frame(&frame);
283 }
284}
285
286/// Log a message at the given level. ISR-safe: pushing a frame does no
287/// formatting and never blocks (see the module docs for why arguments are
288/// a small closed set — `u32`/`i32`/`f32`/`&'static str` — rather than a
289/// full `format_args!`-style template). Up to two `{}` placeholders in
290/// `$msg` are substituted, in order, at drain time:
291///
292/// ```ignore
293/// rivet::log!(Level::Info, "task {} spawned", id);
294/// rivet::log!(Level::Warn, "retry {}/{}", attempt, max);
295/// ```
296#[macro_export]
297macro_rules! log {
298 ($level:expr, $msg:expr) => {
299 $crate::log::push(
300 $level,
301 $msg,
302 $crate::log::LogArg::None,
303 $crate::log::LogArg::None,
304 )
305 };
306 ($level:expr, $msg:expr, $a:expr) => {
307 $crate::log::push(
308 $level,
309 $msg,
310 $crate::log::LogArg::from($a),
311 $crate::log::LogArg::None,
312 )
313 };
314 ($level:expr, $msg:expr, $a:expr, $b:expr) => {
315 $crate::log::push(
316 $level,
317 $msg,
318 $crate::log::LogArg::from($a),
319 $crate::log::LogArg::from($b),
320 )
321 };
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn log_arg_from_conversions() {
330 assert!(matches!(LogArg::from(5u32), LogArg::U32(5)));
331 assert!(matches!(LogArg::from(-3i32), LogArg::I32(-3)));
332 assert!(matches!(LogArg::from("hi"), LogArg::Str("hi")));
333 match LogArg::from(1.5f32) {
334 LogArg::F32(v) => assert!((v - 1.5).abs() < f32::EPSILON),
335 _ => panic!("expected F32"),
336 }
337 }
338
339 /// Pure substitution logic — doesn't touch the ring or console (the
340 /// host `port::host` console write is a no-op, so a real end-to-end
341 /// check of what actually got written needs the QEMU suite;
342 /// `examples/*/src/bin/report_test.rs`'s `hello from A, i={0..4}`
343 /// golden lines are that check). This test isolates `write_interpolated`'s
344 /// placeholder-counting/fallback behavior instead, by checking it
345 /// against a fake sink is impossible without one — so it only
346 /// checks it doesn't panic across the boundary cases (0, 1, 2, and
347 /// more `{}` than args).
348 #[test]
349 fn write_interpolated_boundary_cases_do_not_panic() {
350 write_interpolated("no placeholders", &[LogArg::None, LogArg::None]);
351 write_interpolated("one {}", &[LogArg::U32(1), LogArg::None]);
352 write_interpolated("two {} and {}", &[LogArg::U32(1), LogArg::Str("x")]);
353 write_interpolated("three {} {} {}", &[LogArg::U32(1), LogArg::U32(2)]);
354 }
355}