gunnar_common/logqueue.rs
1//! **The console queue**: the log lines gunnar writes to stderr go through ONE
2//! bounded queue and ONE drainer thread, so no request ever waits for the
3//! console to accept a byte.
4//!
5//! # Why (MEASURED 2026-08-30)
6//!
7//! On the appliance stderr is `/dev/console` = `ttyS0`, a 16550 UART that the
8//! hypervisor paces at its declared 115200 baud — about 30 KB/s on the KVM
9//! rig. A push narrates ~18 `INFO` lines of ~370 bytes each through the
10//! `ssh.connection{…}:git.transfer{…}` span prefix, and every one of them was
11//! written synchronously from the connection's own thread: `tracing`'s fmt
12//! layer holds the writer while the kernel's serial driver waits for the
13//! transmit register byte by byte. On the rig that was **8–50 ms per log
14//! line**, between lines with no work in between; a push-create that the
15//! same binary serves in 3 ms on a developer box took 161–228 ms, and eight
16//! parallel clients got 6–7.6 ops/s because the UART is one device and every
17//! thread queued on it. Stock git, with no console to talk to, created
18//! repositories at 103 ops/s. The serial log's own byte rate over the phase
19//! was 32.0 KB/s — the console was the clock.
20//!
21//! # What this does
22//!
23//! The fmt layer formats each event into a per-event buffer (the
24//! [`MakeWriter`] contract: one writer per event, dropped when the event is
25//! written). On drop the line is handed to a `sync_channel` of [`CAPACITY`]
26//! lines and the thread carries on. A single drainer thread owns stderr and
27//! writes lines in order.
28//!
29//! * `WARN` and `ERROR` **block until the queue has room** — never dropped.
30//! The audit trail (`gunnar::audit`) is `WARN`, the dashboard's alert rows
31//! are `WARN`, and one such line waiting a few milliseconds behind a
32//! backlog is the right trade; a lost one is not.
33//! * `INFO` and below are **dropped when the queue is full**, and counted.
34//! The drainer says so on the console itself — `event="log.dropped"` with
35//! the count — the next time it catches up, so a sampled console never
36//! reads as a complete one. The dashboard is unaffected: it reads the
37//! in-process tap ([`crate::logtap`]), not the console.
38//!
39//! The queue is used when stderr is **not** a terminal: PID 1 on the
40//! appliance, a systemd unit under journald, a test harness with `RUST_LOG`
41//! set. A human at a terminal keeps the synchronous writer: there the console
42//! is fast, and `println!` output interleaving with the log in order matters
43//! more than a few microseconds per line.
44//!
45//! # Exit
46//!
47//! A process that returns from `main` takes whatever is still queued with it,
48//! so the two entry points call [`flush`] on their way out. It waits, bounded,
49//! for the drainer to empty the queue.
50
51use std::io::Write as _;
52use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
53use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
54use std::sync::{Mutex, OnceLock};
55use std::time::{Duration, Instant};
56
57use tracing_subscriber::fmt::MakeWriter;
58
59/// Lines the queue holds before `INFO` starts being dropped. At ~370 bytes a
60/// line this is ~1.5 MB at the very worst, preallocated by the channel once.
61pub const CAPACITY: usize = 4096;
62
63/// Lines the queue could not carry, since the process started.
64static DROPPED: AtomicU64 = AtomicU64::new(0);
65/// Lines that were written to the console, since the process started.
66static WRITTEN: AtomicU64 = AtomicU64::new(0);
67/// Lines currently queued (sent, not yet written). The flush waits on this.
68static QUEUED: AtomicUsize = AtomicUsize::new(0);
69
70static SENDER: OnceLock<SyncSender<Vec<u8>>> = OnceLock::new();
71
72/// **The tap**: every console line handed to one more reader as it is made —
73/// the VM's boot log that the installer collects (gunnar-server
74/// `installer_listener`, 2026-09-24). A plain function, set once; unset, it
75/// costs one atomic load per line.
76static TAP: OnceLock<fn(&str)> = OnceLock::new();
77
78/// Hand every console line to `f` as well. The first caller wins.
79pub fn set_tap(f: fn(&str)) {
80 let _ = TAP.set(f);
81}
82
83/// **The console, when fd 2 is no longer it** ([`crate::stderr_capture`]):
84/// the process's stderr became a pipe so that what libraries print with
85/// `eprintln!` (monetize-server, its guests) reaches the tap as log lines,
86/// and the drainer must then write to the console it was — never back into
87/// the pipe it would read again.
88static CONSOLE: OnceLock<std::fs::File> = OnceLock::new();
89
90/// Name `console` as the device the drainer writes to. Before
91/// [`crate::logging::init`]; the first caller wins.
92pub fn set_console(console: std::fs::File) {
93 let _ = CONSOLE.set(console);
94}
95
96/// What the drainer writes to: the console [`set_console`] named, else stderr.
97pub fn console_sink() -> Box<dyn std::io::Write + Send> {
98 match CONSOLE.get().and_then(|f| f.try_clone().ok()) {
99 Some(f) => Box::new(f),
100 None => Box::new(std::io::stderr()),
101 }
102}
103
104/// Write `text` straight to the console (a PID 1 shout), not through fd 2:
105/// with stderr captured, fd 2 is the pipe and the line would be tapped twice.
106pub fn console_write(text: &str) {
107 let _ = console_sink().write_all(text.as_bytes());
108}
109
110/// A line said OUTSIDE tracing (the boot narrator), handed to the tap too.
111pub fn tap_line(line: &str) {
112 if let Some(f) = TAP.get() {
113 f(line);
114 }
115}
116
117/// Total `INFO`-and-below lines the console could not carry so far.
118pub fn dropped() -> u64 {
119 DROPPED.load(Ordering::Relaxed)
120}
121
122/// Total lines written to the console so far.
123pub fn written() -> u64 {
124 WRITTEN.load(Ordering::Relaxed)
125}
126
127/// Whether the queue is installed (stderr was not a terminal at init).
128pub fn active() -> bool {
129 SENDER.get().is_some()
130}
131
132/// Wait, at most `timeout`, for every queued line to reach the console.
133///
134/// Called by the entry points on their way out; harmless when the queue is
135/// not installed. Bounded because a console that has stopped accepting bytes
136/// must not turn a clean exit into a hang.
137pub fn flush(timeout: Duration) {
138 if SENDER.get().is_none() {
139 return;
140 }
141 let deadline = Instant::now() + timeout;
142 while QUEUED.load(Ordering::Acquire) > 0 && Instant::now() < deadline {
143 std::thread::sleep(Duration::from_millis(2));
144 }
145 let _ = std::io::stderr().flush();
146}
147
148/// Start the drainer and hand back the `MakeWriter` the fmt layer takes.
149///
150/// `sink` is what the drainer writes to — stderr in the process, a fake in the
151/// tests. Installing twice is a no-op that returns the same queue; the second
152/// sink is ignored.
153pub fn install(sink: Box<dyn std::io::Write + Send>) -> QueueWriter {
154 let sender = SENDER.get_or_init(|| {
155 let (tx, rx) = sync_channel::<Vec<u8>>(CAPACITY);
156 // A32-14, stays a task: the console writer itself (a job's ALERT line is written through it); drained at `flush`.
157 std::thread::Builder::new()
158 .name("gunnar-console".to_owned())
159 .spawn(move || drain(rx, sink))
160 .expect("spawning the console drainer thread");
161 tx
162 });
163 QueueWriter {
164 sender: sender.clone(),
165 }
166}
167
168/// The drainer: one thread, owns the sink, writes lines in queue order and
169/// reports the drops it catches up over.
170fn drain(rx: Receiver<Vec<u8>>, mut sink: Box<dyn std::io::Write + Send>) {
171 let mut reported: u64 = 0;
172 while let Ok(line) = rx.recv() {
173 let _ = sink.write_all(&line);
174 WRITTEN.fetch_add(1, Ordering::Relaxed);
175 // Caught up (this was the last line queued): say how much the console
176 // missed, once per catch-up, on the console itself, BEFORE the count
177 // drops to zero — `flush` returns on zero and must see the report
178 // written. The line is short on purpose: it goes to the very device
179 // that could not keep up.
180 if QUEUED.load(Ordering::Acquire) == 1 {
181 let dropped = DROPPED.load(Ordering::Relaxed);
182 if dropped > reported {
183 let _ = writeln!(
184 sink,
185 "gunnar: the console could not carry {} log lines (INFO and below; {dropped} \
186 since start) — WARN rows and the dashboard are complete event=\"log.dropped\" \
187 dropped={} total={dropped}",
188 dropped - reported,
189 dropped - reported
190 );
191 reported = dropped;
192 }
193 let _ = sink.flush();
194 }
195 QUEUED.fetch_sub(1, Ordering::AcqRel);
196 }
197 let _ = sink.flush();
198}
199
200/// The `MakeWriter` half: hands the fmt layer one [`LineWriter`] per event.
201#[derive(Clone)]
202pub struct QueueWriter {
203 sender: SyncSender<Vec<u8>>,
204}
205
206impl<'a> MakeWriter<'a> for QueueWriter {
207 type Writer = LineWriter;
208
209 fn make_writer(&'a self) -> Self::Writer {
210 // Called without metadata only by code outside the fmt layer's event
211 // path; treat it as a line that must not be lost.
212 LineWriter {
213 sender: self.sender.clone(),
214 buf: Vec::with_capacity(512),
215 must_deliver: true,
216 }
217 }
218
219 fn make_writer_for(&'a self, meta: &tracing::Metadata<'_>) -> Self::Writer {
220 LineWriter {
221 sender: self.sender.clone(),
222 buf: Vec::with_capacity(512),
223 must_deliver: *meta.level() <= tracing::Level::WARN,
224 }
225 }
226}
227
228/// One formatted event. Bytes collect here; the line is queued on drop.
229pub struct LineWriter {
230 sender: SyncSender<Vec<u8>>,
231 buf: Vec<u8>,
232 /// `WARN`/`ERROR`: wait for room rather than drop.
233 must_deliver: bool,
234}
235
236impl std::io::Write for LineWriter {
237 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
238 self.buf.extend_from_slice(bytes);
239 Ok(bytes.len())
240 }
241
242 fn flush(&mut self) -> std::io::Result<()> {
243 Ok(())
244 }
245}
246
247impl Drop for LineWriter {
248 fn drop(&mut self) {
249 if self.buf.is_empty() {
250 return;
251 }
252 if let Some(f) = TAP.get() {
253 f(&String::from_utf8_lossy(&self.buf));
254 }
255 let line = std::mem::take(&mut self.buf);
256 QUEUED.fetch_add(1, Ordering::AcqRel);
257 if self.must_deliver {
258 if self.sender.send(line).is_err() {
259 // The drainer is gone (process tearing down): nothing to do.
260 QUEUED.fetch_sub(1, Ordering::AcqRel);
261 }
262 return;
263 }
264 match self.sender.try_send(line) {
265 Ok(()) => {}
266 Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => {
267 QUEUED.fetch_sub(1, Ordering::AcqRel);
268 DROPPED.fetch_add(1, Ordering::Relaxed);
269 }
270 }
271 }
272}
273
274/// A sink for the tests: every byte written, behind a mutex, with an optional
275/// per-write delay that plays the paced UART.
276#[doc(hidden)]
277pub struct SlowSink {
278 pub bytes: std::sync::Arc<Mutex<Vec<u8>>>,
279 pub delay: Duration,
280}
281
282impl std::io::Write for SlowSink {
283 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
284 if !self.delay.is_zero() {
285 std::thread::sleep(self.delay);
286 }
287 self.bytes
288 .lock()
289 .unwrap_or_else(|e| e.into_inner())
290 .extend_from_slice(bytes);
291 Ok(bytes.len())
292 }
293
294 fn flush(&mut self) -> std::io::Result<()> {
295 Ok(())
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use std::sync::Arc;
303
304 /// The one statement this module exists for: a console that takes 5 ms
305 /// per line costs the thread that logs nothing like 5 ms per line. WARN
306 /// lines all arrive; INFO lines past the queue's capacity are dropped and
307 /// counted; the drainer reports the drop on the console.
308 #[test]
309 fn a_slow_console_never_stalls_the_logger_and_loses_no_warn() {
310 let bytes = Arc::new(Mutex::new(Vec::new()));
311 let writer = install(Box::new(SlowSink {
312 bytes: Arc::clone(&bytes),
313 delay: Duration::from_millis(5),
314 }));
315 let info = tracing::Metadata::new(
316 "t",
317 "t",
318 tracing::Level::INFO,
319 None,
320 None,
321 None,
322 tracing::field::FieldSet::new(&[], tracing::callsite::Identifier(&CALLSITE)),
323 tracing::metadata::Kind::EVENT,
324 );
325 let warn = tracing::Metadata::new(
326 "t",
327 "t",
328 tracing::Level::WARN,
329 None,
330 None,
331 None,
332 tracing::field::FieldSet::new(&[], tracing::callsite::Identifier(&CALLSITE)),
333 tracing::metadata::Kind::EVENT,
334 );
335 let n = CAPACITY * 3;
336 let started = Instant::now();
337 for i in 0..n {
338 let mut w = writer.make_writer_for(&info);
339 writeln!(w, "info line {i}").unwrap();
340 }
341 let info_elapsed = started.elapsed();
342 for i in 0..8 {
343 let mut w = writer.make_writer_for(&warn);
344 writeln!(w, "WARN line {i}").unwrap();
345 }
346 // 12 288 INFO lines at 5 ms each would be a minute; the logger must be
347 // done in well under one second.
348 assert!(
349 info_elapsed < Duration::from_secs(1),
350 "logging {n} INFO lines to a 5 ms/line console took {info_elapsed:?}: the logger waited on the console"
351 );
352 let dropped_now = dropped();
353 assert!(
354 dropped_now > 0 && dropped_now < n as u64,
355 "expected some but not all INFO lines dropped past a {CAPACITY}-line queue, got {dropped_now} of {n}"
356 );
357 flush(Duration::from_secs(120));
358 let out = String::from_utf8_lossy(&bytes.lock().unwrap()).into_owned();
359 for i in 0..8 {
360 assert!(
361 out.contains(&format!("WARN line {i}\n")),
362 "WARN line {i} was lost"
363 );
364 }
365 assert!(
366 out.contains("event=\"log.dropped\""),
367 "the drainer must report on the console what the console missed"
368 );
369 assert_eq!(
370 written() as usize,
371 n - dropped_now as usize + 8,
372 "every line not dropped was written"
373 );
374 }
375
376 struct Callsite;
377 impl tracing::Callsite for Callsite {
378 fn set_interest(&self, _: tracing::subscriber::Interest) {}
379 fn metadata(&self) -> &tracing::Metadata<'_> {
380 unreachable!("the test never registers this callsite")
381 }
382 }
383 static CALLSITE: Callsite = Callsite;
384}