Skip to main content

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}