Skip to main content

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}