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}
132static DROPPED: AtomicU32 = AtomicU32::new(0);
133
134/// Called once from [`crate::init`]. Splitting the channel here (rather
135/// than lazily on first use) means the first `log!` call anywhere is
136/// never the one paying for initialization, and keeps `push`'s hot path
137/// to just a critical-section-guarded `try_send`.
138pub(crate) fn init() {
139 if let Some((tx, rx)) = CHANNEL.split() {
140 let _ = SENDER.set(tx);
141 let _ = RECEIVER.set(rx);
142 }
143}
144
145/// Push a frame. ISR-safe: no allocation, no unbounded loops, never
146/// blocks — a full ring drops the frame and counts it (see
147/// [`dropped_frames`]) rather than backing up whatever called this.
148#[doc(hidden)]
149pub fn push(level: Level, msg: &'static str, arg0: LogArg, arg1: LogArg) {
150 let task_id = crate::preempt::sched::current().map(|id| id as u16);
151 let timestamp_us = crate::port::board::now_us() as u32;
152 let frame = LogFrame {
153 level,
154 task_id,
155 timestamp_us,
156 msg,
157 args: [arg0, arg1],
158 };
159 let sent = match SENDER.get() {
160 // SAFETY-relevant, not memory-safety: `try_send` requires a
161 // single logical producer; the critical section serializes
162 // however many concurrent callers (tasks and/or ISRs) there are
163 // into one.
164 Some(tx) => crate::critical::enter(|| tx.try_send(frame)).is_ok(),
165 None => false,
166 };
167 if !sent {
168 DROPPED.fetch_add(1, Ordering::Relaxed);
169 }
170}
171
172/// Number of frames dropped so far because the ring was full (or logging
173/// hadn't been initialized yet — i.e. called before [`crate::init`]).
174pub fn dropped_frames() -> usize {
175 DROPPED.load(Ordering::Relaxed) as usize
176}
177
178fn write_frame(frame: &LogFrame) {
179 crate::console::write_str("[");
180 crate::console::write_str(frame.level.as_str());
181 crate::console::write_str("] t=");
182 write_dec(frame.timestamp_us as usize);
183 if let Some(id) = frame.task_id {
184 crate::console::write_str(" task=");
185 write_dec(id as usize);
186 }
187 crate::console::write_str(" ");
188 write_interpolated(frame.msg, &frame.args);
189 crate::console::write_str("\n");
190}
191
192/// Write `msg`, substituting each `{}` (in order) with the corresponding
193/// entry of `args`. Extra `{}` beyond the two argument slots are written
194/// through literally, since there's nothing to fill them with — no
195/// silent truncation of the message.
196fn write_interpolated(msg: &str, args: &[LogArg; 2]) {
197 let mut rest = msg;
198 let mut arg_idx = 0usize;
199 while let Some(pos) = rest.find("{}") {
200 crate::console::write_str(&rest[..pos]);
201 match args.get(arg_idx) {
202 Some(LogArg::None) | None => crate::console::write_str("{}"),
203 Some(arg) => write_arg(arg),
204 }
205 arg_idx += 1;
206 rest = &rest[pos + 2..];
207 }
208 crate::console::write_str(rest);
209}
210
211fn write_arg(arg: &LogArg) {
212 match *arg {
213 LogArg::None => {}
214 LogArg::U32(v) => crate::console::_print(core::format_args!("{v}")),
215 LogArg::I32(v) => crate::console::_print(core::format_args!("{v}")),
216 LogArg::F32(v) => crate::console::_print(core::format_args!("{v}")),
217 LogArg::Str(s) => crate::console::write_str(s),
218 }
219}
220
221fn write_dec(mut n: usize) {
222 if n == 0 {
223 crate::console::write_str("0");
224 return;
225 }
226 let mut digits = [0u8; 20];
227 let mut i = 0;
228 while n > 0 {
229 digits[i] = b'0' + (n % 10) as u8;
230 n /= 10;
231 i += 1;
232 }
233 let mut buf = [0u8; 20];
234 for j in 0..i {
235 buf[j] = digits[i - 1 - j];
236 }
237 if let Ok(s) = core::str::from_utf8(&buf[..i]) {
238 crate::console::write_str(s);
239 }
240}
241
242/// Drain and format one pending frame. Returns `false` if the ring was
243/// empty. Call this in a loop from a low-priority task to flush the log
244/// (see [`drain_forever`] for a ready-made one).
245pub fn drain_one() -> bool {
246 match RECEIVER.get().and_then(|rx| rx.try_recv()) {
247 Some(frame) => {
248 write_frame(&frame);
249 true
250 }
251 None => false,
252 }
253}
254
255/// A ready-made drain loop: `.await`s new frames and writes them to the
256/// console as they arrive. Spawn this as a low-priority `#[rivet::task]`
257/// if you want logging without writing your own drain loop.
258///
259/// ```ignore
260/// #[rivet::task(priority = 0)]
261/// async fn log_drain() {
262/// rivet::log::drain_forever().await;
263/// }
264/// ```
265pub async fn drain_forever() -> ! {
266 let rx = loop {
267 if let Some(rx) = RECEIVER.get() {
268 break rx;
269 }
270 // Logging hasn't been initialized yet (called before rivet::init)
271 // — extremely unlikely given normal boot order, but don't spin
272 // hot if it happens.
273 crate::time::Sleep::<1000>::new().await;
274 };
275 loop {
276 let frame = rx.recv().await;
277 write_frame(&frame);
278 }
279}
280
281/// Log a message at the given level. ISR-safe: pushing a frame does no
282/// formatting and never blocks (see the module docs for why arguments are
283/// a small closed set — `u32`/`i32`/`f32`/`&'static str` — rather than a
284/// full `format_args!`-style template). Up to two `{}` placeholders in
285/// `$msg` are substituted, in order, at drain time:
286///
287/// ```ignore
288/// rivet::log!(Level::Info, "task {} spawned", id);
289/// rivet::log!(Level::Warn, "retry {}/{}", attempt, max);
290/// ```
291#[macro_export]
292macro_rules! log {
293 ($level:expr, $msg:expr) => {
294 $crate::log::push(
295 $level,
296 $msg,
297 $crate::log::LogArg::None,
298 $crate::log::LogArg::None,
299 )
300 };
301 ($level:expr, $msg:expr, $a:expr) => {
302 $crate::log::push(
303 $level,
304 $msg,
305 $crate::log::LogArg::from($a),
306 $crate::log::LogArg::None,
307 )
308 };
309 ($level:expr, $msg:expr, $a:expr, $b:expr) => {
310 $crate::log::push(
311 $level,
312 $msg,
313 $crate::log::LogArg::from($a),
314 $crate::log::LogArg::from($b),
315 )
316 };
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn log_arg_from_conversions() {
325 assert!(matches!(LogArg::from(5u32), LogArg::U32(5)));
326 assert!(matches!(LogArg::from(-3i32), LogArg::I32(-3)));
327 assert!(matches!(LogArg::from("hi"), LogArg::Str("hi")));
328 match LogArg::from(1.5f32) {
329 LogArg::F32(v) => assert!((v - 1.5).abs() < f32::EPSILON),
330 _ => panic!("expected F32"),
331 }
332 }
333
334 /// Pure substitution logic — doesn't touch the ring or console (the
335 /// host `port::host` console write is a no-op, so a real end-to-end
336 /// check of what actually got written needs the QEMU suite;
337 /// `examples/*/src/bin/report_test.rs`'s `hello from A, i={0..4}`
338 /// golden lines are that check). This test isolates `write_interpolated`'s
339 /// placeholder-counting/fallback behavior instead, by checking it
340 /// against a fake sink is impossible without one — so it only
341 /// checks it doesn't panic across the boundary cases (0, 1, 2, and
342 /// more `{}` than args).
343 #[test]
344 fn write_interpolated_boundary_cases_do_not_panic() {
345 write_interpolated("no placeholders", &[LogArg::None, LogArg::None]);
346 write_interpolated("one {}", &[LogArg::U32(1), LogArg::None]);
347 write_interpolated("two {} and {}", &[LogArg::U32(1), LogArg::Str("x")]);
348 write_interpolated("three {} {} {}", &[LogArg::U32(1), LogArg::U32(2)]);
349 }
350}