crcbl_core/log/console.rs
1//! The console's view of the log: the ring every sink feeds, and the target the
2//! console's own output carries.
3//!
4//! Debug-console decision 4 in `docs/notes/tooling.md` is the design. The panel
5//! shows **the** log rather than a second one, so there is one bounded ring
6//! here and every sink pushes into it — [`crate::log`]'s own `StderrLogger`
7//! natively and `crcbl::web`'s `WebLogger` in a browser, through the one
8//! [`push`] both call.
9//!
10//! Two things follow from where the push sits, and both are deliberate:
11//!
12//! * **It is before the sink's level filter**, so the ring holds a record the
13//! terminal decided not to print and the panel can be asked to show it. The
14//! facade's global maximum still applies — a call site whose level is above
15//! `log::max_level()` never reaches a sink at all — so what the ring adds is
16//! every record a *per-target* directive silenced.
17//! * **It renders the message on every record**, where [`crate::log::capture`]
18//! renders only when a thread asked. That is the cost decision 10 prices: one
19//! `String` on a path that was already going to format one, plus a lock and a
20//! `VecDeque` insert.
21//!
22//! There is no `clear_ring`. `clear` is the panel emptying its own view —
23//! Source's `clear` empties the console, not the file — which is why the ask
24//! lives on `crcbl_console::Context::request_clear` and not here.
25
26use std::collections::VecDeque;
27use std::fmt;
28use std::sync::{Mutex, MutexGuard, PoisonError};
29use std::time::Duration;
30
31use super::Level;
32
33/// The target every line the console itself prints carries.
34///
35/// The console's own output — an echoed command, a variable's value, `help` —
36/// goes through [`print()`], so it is on stderr, in the browser console and in
37/// the ring at once, and the terminal and the panel cannot show different text.
38pub const CONSOLE_TARGET: &str = "console";
39
40/// How many records the ring holds before the oldest is dropped.
41///
42/// Deep enough that the panel scrolls back past the boot sequence of a demo,
43/// which is the run nobody is watching when it goes wrong, and shallow enough
44/// that the ring's memory stays a rounding error beside one frame's textures.
45/// It is a bound rather than a number to tune — a run logs without limit, and
46/// something has to say where the memory stops. Never measured on any tier;
47/// the backlog says so.
48pub const CONSOLE_RING_LINES: usize = 1024;
49
50/// One record, as the console reads it.
51///
52/// Distinct from [`crate::log::CapturedRecord`], which is a *test's* view and
53/// carries no time: a panel draws the elapsed seconds and needs a cursor into
54/// the ring, and a capture assertion wants neither.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct Record {
57 /// Where this record sits in the run's order, counted from the first record
58 /// pushed and never reused.
59 ///
60 /// What [`snapshot_since`] takes, so a reader that draws every frame copies
61 /// the lines that arrived since it last looked instead of the whole ring.
62 pub sequence: u64,
63 /// The level the record was logged at.
64 pub level: Level,
65 /// The record's target — the module path, unless the call overrode it, as
66 /// [`print()`] does.
67 pub target: String,
68 /// The formatted message.
69 pub message: String,
70 /// How long into the run the record was logged, as the sink measured it.
71 pub elapsed: Duration,
72}
73
74/// The bounded ring itself.
75///
76/// A type of its own rather than two globals, so the bound and the sequence can
77/// be tested on an instance: a boundedness assertion written against the process
78/// ring would be answered by whatever else the test binary logged.
79#[derive(Debug)]
80struct Ring {
81 /// Oldest first, at most [`CONSOLE_RING_LINES`] of them.
82 records: VecDeque<Record>,
83 /// The sequence the next record gets.
84 next_sequence: u64,
85}
86
87impl Ring {
88 const fn new() -> Self {
89 Self {
90 records: VecDeque::new(),
91 next_sequence: 0,
92 }
93 }
94
95 /// Adds one record, dropping the oldest if the ring is full.
96 fn push(&mut self, level: Level, target: &str, message: String, elapsed: Duration) {
97 if self.records.len() >= CONSOLE_RING_LINES {
98 self.records.pop_front();
99 }
100 let sequence = self.next_sequence;
101 self.next_sequence += 1;
102 self.records.push_back(Record {
103 sequence,
104 level,
105 target: target.to_owned(),
106 message,
107 elapsed,
108 });
109 }
110
111 /// Every record from `from` onwards.
112 ///
113 /// Sequences increase along the ring, so the start is a binary search rather
114 /// than a scan — the whole point of the cursor is that a reader drawing at
115 /// frame rate does not walk every line it has already seen.
116 fn since(&self, from: u64) -> Vec<Record> {
117 let at = self
118 .records
119 .partition_point(|record| record.sequence < from);
120 self.records.iter().skip(at).cloned().collect()
121 }
122}
123
124/// The process's ring.
125static RING: Mutex<Ring> = Mutex::new(Ring::new());
126
127/// The ring, with a poisoned lock stepped over rather than propagated.
128///
129/// A thread that panicked while holding this left a `VecDeque` that is still
130/// structurally sound — the push is one insert — and taking every later log
131/// record down with it would mean logging could end a run, which is the opposite
132/// of what a log is for. The same argument `crate::log::capture` makes about its
133/// own setup lock.
134fn ring() -> MutexGuard<'static, Ring> {
135 RING.lock().unwrap_or_else(PoisonError::into_inner)
136}
137
138/// Pushes one record into the ring.
139///
140/// **What a sink calls, before its own level filter**, and the one push both of
141/// the engine's sinks make: `StderrLogger::emit` here and `WebLogger::log` in
142/// `crates/crcbl/src/web.rs`. `elapsed` is the sink's own clock, because the two
143/// do not share one — the native side measures from an `Instant` taken at
144/// install, the browser from `performance.now()` at the first frame.
145pub fn push(level: Level, target: &str, elapsed: Duration, args: fmt::Arguments<'_>) {
146 // Rendered before the lock is taken: `args` runs the caller's own `Display`
147 // impls, and one of those logging in turn would deadlock on the ring.
148 let message = args.to_string();
149 ring().push(level, target, message, elapsed);
150}
151
152/// Logs one line the console produced, under [`CONSOLE_TARGET`].
153///
154/// Everything the console prints goes through here, so a line is on stderr, in
155/// the browser console and in the ring at once. It goes to whichever sink the
156/// process installed rather than to the ring directly, for the reason
157/// [`crate::log::__emit`] gives: on `wasm32` that sink is not this crate's.
158///
159/// At [`Level::Info`], and past the macros' `__enabled` fast path, so the line
160/// reaches the ring whatever the facade's global maximum happens to be. The
161/// sink's own filter still decides the terminal: under `CRCBL_LOG=off` the
162/// answer to a typed command is in the panel and not on stderr.
163pub fn print(line: &str) {
164 super::__emit(Level::Info, CONSOLE_TARGET, format_args!("{line}"));
165}
166
167/// A copy of the whole ring, oldest first.
168///
169/// What a panel reads when it opens. Copies rather than drains: the ring is the
170/// run's, and a second reader must see the same lines.
171#[must_use]
172pub fn snapshot() -> Vec<Record> {
173 // The oldest record the ring still holds is the oldest sequence it holds, so
174 // "everything" and "everything since the beginning" are one copy and not two.
175 snapshot_since(0)
176}
177
178/// A copy of every record from sequence `from` onwards, oldest first.
179///
180/// What a panel reads on every later frame: keep `last.sequence + 1` from the
181/// previous call and pass it here, and the copy is the new lines only. A cursor
182/// older than the ring still holds yields whatever survived, which is the
183/// honest answer — the records between were dropped, not hidden.
184#[must_use]
185pub fn snapshot_since(from: u64) -> Vec<Record> {
186 ring().since(from)
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 /// Pushes `count` records into `ring`, numbered in their message.
194 fn fill(ring: &mut Ring, count: usize) {
195 for i in 0..count {
196 ring.push(
197 Level::Info,
198 "crcbl_core::log::console",
199 format!("line {i}"),
200 Duration::from_millis(i as u64),
201 );
202 }
203 }
204
205 /// **The ring is bounded, and it drops the oldest.**
206 ///
207 /// A ring that dropped the newest would lose whatever just went wrong, and
208 /// an unbounded one would grow a long run's memory without limit — which is
209 /// the whole reason the depth is a constant and not a `Vec`.
210 #[test]
211 fn past_its_depth_the_oldest_records_are_the_ones_that_go() {
212 let overflow = 3;
213 let mut ring = Ring::new();
214 fill(&mut ring, CONSOLE_RING_LINES + overflow);
215
216 let held = ring.since(0);
217 assert_eq!(held.len(), CONSOLE_RING_LINES, "the bound did not hold");
218 assert_eq!(
219 held[0].message,
220 format!("line {overflow}"),
221 "the oldest three should have gone, not the newest",
222 );
223 assert_eq!(
224 held.last().expect("the ring is not empty").message,
225 format!("line {}", CONSOLE_RING_LINES + overflow - 1),
226 );
227 }
228
229 /// **A sequence is never reused and never skipped**, because that is what
230 /// makes it a cursor: a reader that saw `n` asks for `n + 1` and gets
231 /// exactly what arrived since.
232 #[test]
233 fn sequences_stay_contiguous_across_the_drop() {
234 let overflow = 3;
235 let mut ring = Ring::new();
236 fill(&mut ring, CONSOLE_RING_LINES + overflow);
237
238 let held = ring.since(0);
239 let first = held[0].sequence;
240 assert_eq!(
241 first, overflow as u64,
242 "the sequence must count every record pushed, dropped ones included",
243 );
244 for (step, record) in held.iter().enumerate() {
245 assert_eq!(
246 record.sequence,
247 first + step as u64,
248 "a gap or a repeat at {step}",
249 );
250 }
251 }
252
253 /// The cursor is what a per-frame reader costs nothing on.
254 #[test]
255 fn a_cursor_takes_the_newer_records_and_nothing_else() {
256 let mut ring = Ring::new();
257 fill(&mut ring, 5);
258
259 let all = ring.since(0);
260 assert_eq!(all.len(), 5);
261
262 let newer = ring.since(3);
263 assert_eq!(
264 newer.iter().map(|r| r.sequence).collect::<Vec<_>>(),
265 [3, 4],
266 "only the records at or after the cursor",
267 );
268 assert_eq!(newer[0].message, "line 3");
269 assert!(
270 ring.since(5).is_empty(),
271 "a cursor past the end has nothing to hand back",
272 );
273 }
274
275 /// The record carries what a panel draws, not merely the text.
276 #[test]
277 fn a_record_keeps_its_level_target_and_elapsed() {
278 let mut ring = Ring::new();
279 ring.push(
280 Level::Warn,
281 CONSOLE_TARGET,
282 "something to say".to_owned(),
283 Duration::from_millis(1250),
284 );
285
286 let held = ring.since(0);
287 let record = held.first().expect("the record was pushed");
288 assert_eq!(record.level, Level::Warn);
289 assert_eq!(record.target, "console");
290 assert_eq!(record.message, "something to say");
291 assert_eq!(record.elapsed, Duration::from_millis(1250));
292 }
293
294 /// The process ring is fed by the same code the instance tests exercise, so
295 /// this only has to prove the wiring — that [`push`] reaches it at all.
296 #[test]
297 fn the_process_ring_takes_what_push_is_given() {
298 let target = "crcbl_core::log::console::the_process_ring_takes_what_push_is_given";
299 push(
300 Level::Error,
301 target,
302 Duration::from_secs(2),
303 format_args!("a record from {}", "the shared push"),
304 );
305
306 let mine: Vec<Record> = snapshot()
307 .into_iter()
308 .filter(|record| record.target == target)
309 .collect();
310 assert_eq!(mine.len(), 1, "{mine:?}");
311 assert_eq!(mine[0].message, "a record from the shared push");
312 assert_eq!(mine[0].level, Level::Error);
313 assert_eq!(mine[0].elapsed, Duration::from_secs(2));
314 }
315}