gunnar-common 2.0.0

Shared command line, logging, and build identity for the gunnar binaries
Documentation
//! **What a library prints on stderr becomes a log line** (feature
//! `stderr-capture`, 2026-09-28).
//!
//! # Why
//!
//! MEASURED 2026-09-28 on test2's front (vm1, gunnar 50c3e69) through the
//! installer's log door: a tenant's order (`subscription.bought … state=
//! "pending"`) and then NOTHING from monetize — no checkout creation, no
//! Stripe call, no webhook, no poller pass, no capture. monetize-server runs
//! IN the front's process (`gunnar_front::monetize::serve_thread`) and says
//! everything with `eprintln!("monetize-server: …")` (≈100 call sites in
//! monetize-server alone). `eprintln!` writes fd 2 — on PID 1 the serial
//! console — and never passes tracing, so none of it reached the boot log the
//! installer reads (`installer_listener::BOOT_LOG`, fed by the logqueue tap).
//!
//! # What this does
//!
//! [`start`] is called at the top of `main`, BEFORE tracing is installed:
//!
//! 1. the console fd 2 was is duplicated and named as the drainer's sink
//!    ([`crate::logqueue::set_console`]) — tracing keeps writing to the real
//!    console, never into the pipe below;
//! 2. a pipe replaces fd 2 (`dup2`), so every `eprintln!`/`eprint!` in the
//!    process — monetize-server, its wasm guests' stderr, any crate — lands in
//!    the pipe;
//! 3. one thread reads the pipe line by line and says each line through
//!    `tracing` under target `stderr` — so it gets a timestamp and a level,
//!    reaches the console through the queue, the dashboard's tap, and the
//!    boot log's tap, where the VM's redaction (nordisk-pairing `redact`)
//!    withholds what must never leave the box.
//!
//! A line that says it failed (`FAILED`, `REFUSED`, `panicked`, ` error`) is
//! a `WARN` — never dropped by a full console queue; the rest are `INFO`.

use std::os::fd::AsFd as _;

/// The tracing target stderr lines are said under.
pub const TARGET: &str = "stderr";

/// The level a captured line is said at: `WARN` when it says it failed.
pub fn is_warning(line: &str) -> bool {
    line.contains("FAILED") || line.contains("REFUSED") || line.contains("panicked") || line.to_ascii_lowercase().contains(" error")
}

/// Say one captured line through tracing.
pub fn say(line: &str) {
    let line = line.trim_end_matches(['\r', '\n']);
    if line.trim().is_empty() {
        return;
    }
    if is_warning(line) {
        tracing::warn!(target: "stderr", "{line}");
    } else {
        tracing::info!(target: "stderr", "{line}");
    }
}

/// **Take fd 2 over** — see the module doc. Returns an error, and changes
/// nothing, when the console cannot be duplicated or the pipe cannot be made;
/// the caller says so and carries on with stderr as it was. Once per process.
pub fn start() -> std::io::Result<()> {
    static STARTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
    if STARTED.get().is_some() {
        return Ok(());
    }
    let console: std::fs::File = std::io::stderr().as_fd().try_clone_to_owned()?.into();
    let (read, write) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC)?;
    let reader = std::io::BufReader::new(std::fs::File::from(read));
    // A32-14, stays a task: the pipe's reader for the life of fd 2; its start failure is the caller's answer.
    std::thread::Builder::new().name("stderr-capture".into()).spawn(move || pump(reader))?;
    crate::logqueue::set_console(console);
    rustix::stdio::dup2_stderr(&write)?;
    drop(write);
    let _ = STARTED.set(());
    Ok(())
}

/// The reader: one line at a time, lossy UTF-8, until the pipe closes.
pub fn pump(mut reader: impl std::io::BufRead) {
    let mut buf = Vec::with_capacity(1024);
    loop {
        buf.clear();
        match reader.read_until(b'\n', &mut buf) {
            Ok(0) | Err(_) => return,
            Ok(_) => say(&String::from_utf8_lossy(&buf)),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_line_that_says_it_failed_is_a_warning() {
        assert!(is_warning("monetize-server: grow FAILED: the cloud said no"));
        assert!(is_warning("monetize-server: entitlement push REFUSED for gunnar/alice, not retried"));
        assert!(is_warning("monetize-server: poller panicked: index out of bounds"));
        assert!(!is_warning("monetize-server: listening on 127.0.0.1:50061 (gRPC h2c + POST /callback/<vendor>)"));
    }
}