Files
benilla-octo/crates/benilla-world/src/log_ring.rs
T
gromandClaude Opus 5 3369d525cb log: the client keeps its own log, and says which zone and room it thinks it is in
A player sees something wrong for two seconds and quits. What the client said at
that moment lived only in the terminal it was launched from — which, for a player
who starts it from a desktop, is nowhere. So three things, all aimed at the next
report being answerable from the machine it happened on.

**The session log.** `benilla-config/Logs/benilla.log` now holds every line that
goes to stderr, with the previous two runs kept beside it as `benilla-1.log` and
`benilla-2.log`. The ring that feeds the crash report already formats these lines
([`log_ring`]), so the file is one more sink on it: the ring's backlog replays into
the file when it opens, so the boot lines are not lost to the attach being late,
and writes are unbuffered because the point of the file is to survive the crash
that made someone read it. 64 MiB cap, then one line saying so. A capture run
resolves no state folder and writes none. Player chat is logged at `debug` and so
is not in it; server notices are.

**The zone line.** One line per change naming the whole chain: the leaf area id
the terrain chunk under the player carries, the zone it rolls up to, and what the
world map selected from that. This is unanswerable otherwise — the ids live for a
frame, and the map only shows the end of the chain. It is already earning itself:
the player reported the map naming a far-away zone in a custom area, and the
terrain says why. `Azeroth_36_43.adt` (the server's own, out of `patch-A.mpq`)
tags 44 of its 256 chunks `5599 "Rugford's Mountain Rest"` — the custom area the
quest is in — and **53 of them `3 "Badlands"`**, in a block that starts a few
chunks south of it. Walk off the rest and the client reads Badlands off their
terrain and moves the map there, exactly as the reference would.

**The interior line.** One line per room change saying whether the camera is
inside a WMO group and which, because that claim decides whether the open world is
drawn at all: inside, the exterior is culled to what the portal flood reaches. "It
all went grey but I could see at some angles" is a description of that state, and
from the outside a wrong claim and a right one look identical.

`log_ring::attach_file` and `FILE_CAP_BYTES` are published engine API: the engine
owns the log, the app owns where this player's state lives. The wall caught both
the moment they crossed, which is what re-baselining it was for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-18 17:34:17 -06:00

209 lines
7.8 KiB
Rust

//! 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<VecDeque<String>> = Mutex::new(VecDeque::new());
static START: OnceLock<Instant> = OnceLock::new();
/// The session's log file and what it has written, once [`attach_file`] opened one.
static FILE: Mutex<Option<Sink>> = 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<usize> {
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<S: Subscriber> Layer<S> 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<String> {
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);
}
}