//! The last [`CAPACITY`] log lines, kept in memory so a crash report carries what the client was //! doing when it died. Installed as [`bevy::log::LogPlugin::custom_layer`] by //! [`crate::boot::tuned_default_plugins`], under the same `EnvFilter` as stderr, so it holds //! exactly the lines stderr showed. Read with [`recent`]. //! //! The same lines also go to a **file**, once a caller that knows where state lives opens one //! ([`attach_file`]) — the app does it at boot. A player reporting "it looked wrong for a second" //! can only be answered from what the client said at the time, and a client whose log exists only //! in the terminal it was launched from says nothing an hour later. The backlog the ring already //! holds is replayed into the file first, so the boot lines are not lost to the attach being late. use std::collections::VecDeque; use std::fmt::Write as _; use std::io::Write as _; use std::path::Path; use std::sync::{Mutex, OnceLock}; use std::time::Instant; use bevy::log::tracing::field::{Field, Visit}; use bevy::log::tracing::{Event, Subscriber}; use bevy::log::tracing_subscriber::layer::Context; use bevy::log::tracing_subscriber::Layer; /// How many lines the ring keeps: about a minute of an ordinary session's `info` stream. pub const CAPACITY: usize = 200; static RING: Mutex> = Mutex::new(VecDeque::new()); static START: OnceLock = OnceLock::new(); /// The session's log file and what it has written, once [`attach_file`] opened one. static FILE: Mutex> = Mutex::new(None); /// How much one session's file may hold before it stops growing: a runaway warn loop must not /// fill the player's disk, and 64 MiB is far more than any session has produced (the longest /// measured, with the reload burst in it, was 1 MiB). pub const FILE_CAP_BYTES: u64 = 64 * 1024 * 1024; /// The open log file. Unbuffered on purpose: the point of the file is to survive the crash that /// made someone read it, and a line costs one `write` into the page cache. struct Sink { file: std::fs::File, written: u64, capped: bool, } impl Sink { fn write_line(&mut self, line: &str) { if self.capped { return; } if self.written >= FILE_CAP_BYTES { self.capped = true; let _ = writeln!( self.file, "— log file capped at {} MiB; the rest of this session is in the terminal only —", FILE_CAP_BYTES / (1024 * 1024) ); return; } if writeln!(self.file, "{line}").is_ok() { self.written += line.len() as u64 + 1; } } } /// Send every line from here on to `path`, after replaying the ring's backlog into it. Returns /// how many backlog lines were replayed, or the error that stopped the file being opened — the /// caller logs it, because logging is this module's whole business and a failure must not be /// silent. Opening a second file replaces the first. pub fn attach_file(path: &Path) -> std::io::Result { if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } let file = std::fs::File::create(path)?; let mut sink = Sink { file, written: 0, capped: false, }; let backlog = recent(); for line in &backlog { sink.write_line(line); } *FILE.lock().unwrap_or_else(|p| p.into_inner()) = Some(sink); Ok(backlog.len()) } /// Whether a file is attached, for the tests and for a caller deciding whether to say where it is. pub fn file_attached() -> bool { FILE.lock() .unwrap_or_else(|p| p.into_inner()) .as_ref() .is_some() } /// The layer; its state is a static so the panic hook, which has no handle to the subscriber, can /// read it. pub struct LogRing; impl Layer for LogRing { fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { let start = START.get_or_init(Instant::now); let meta = event.metadata(); let mut line = format!( "+{:9.3}s {:5} {}: ", start.elapsed().as_secs_f64(), meta.level(), meta.target() ); event.record(&mut MessageVisitor(&mut line)); if let Some(sink) = FILE.lock().unwrap_or_else(|p| p.into_inner()).as_mut() { sink.write_line(&line); } let mut ring = RING.lock().unwrap_or_else(|p| p.into_inner()); if ring.len() >= CAPACITY { ring.pop_front(); } ring.push_back(line); } } /// Renders an event's fields as the stderr formatter does: `message` bare, the rest as /// ` name=value`. struct MessageVisitor<'a>(&'a mut String); impl Visit for MessageVisitor<'_> { fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { if field.name() == "message" { let _ = write!(self.0, "{value:?}"); } else { let _ = write!(self.0, " {}={:?}", field.name(), value); } } fn record_str(&mut self, field: &Field, value: &str) { if field.name() == "message" { self.0.push_str(value); } else { let _ = write!(self.0, " {}={value:?}", field.name()); } } } /// A snapshot of the ring, oldest first. pub fn recent() -> Vec { RING.lock() .unwrap_or_else(|p| p.into_inner()) .iter() .cloned() .collect() } #[cfg(test)] mod tests { use super::*; use bevy::log::tracing_subscriber::layer::SubscriberExt; /// One test for the whole contract, since the ring **and the file** are process-wide statics /// and a second test would interleave with this one's lines. #[test] fn the_ring_keeps_the_last_lines_in_order_and_the_file_takes_the_backlog() { let dir = std::env::temp_dir().join(format!("benilla-logring-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let path = dir.join("nested/benilla.log"); let subscriber = bevy::log::tracing_subscriber::registry().with(LogRing); let body = bevy::log::tracing::subscriber::with_default(subscriber, || { for i in 0..(CAPACITY + 5) { bevy::log::tracing::info!(n = i, "ring line"); } // The ring first, while nothing else has been logged into it. let lines = recent(); assert_eq!(lines.len(), CAPACITY, "bounded at CAPACITY"); let first = &lines[0]; assert!( first.contains("ring line n=5"), "oldest surviving line: {first}" ); assert!(first.contains(" INFO "), "level rendered: {first}"); assert!( lines .last() .unwrap() .contains(&format!("n={}", CAPACITY + 4)), "newest last: {}", lines.last().unwrap() ); // Then the file, which opens late as the app's does: the backlog goes in first, the // live lines after. let replayed = attach_file(&path).expect("the directory is created and the file opens"); assert_eq!(replayed, CAPACITY, "the whole ring replayed"); assert!(file_attached()); bevy::log::tracing::info!("after the file opened: LIVE_MARK"); std::fs::read_to_string(&path).expect("the file is on disk") }); assert!( body.contains(&format!("n={}", CAPACITY + 4)), "the backlog's newest line reached the file" ); assert!( body.contains("LIVE_MARK"), "and so did a line logged after it opened" ); // Close the sink before the directory goes, so nothing later writes into a deleted path. *FILE.lock().unwrap_or_else(|p| p.into_inner()) = None; let _ = std::fs::remove_dir_all(&dir); } }