use std::sync::atomic::{AtomicU8, Ordering};
fn nothing_is_listening() -> bool {
tracing::level_filters::LevelFilter::current() == tracing::level_filters::LevelFilter::OFF
}
fn console_fallback(line: &std::fmt::Arguments<'_>) {
if nothing_is_listening() {
eprintln!("{line}");
}
}
struct ConsoleSubscriber;
struct ConsoleLine {
out: String,
wrote_message: bool,
}
impl tracing::field::Visit for ConsoleLine {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
use std::fmt::Write;
if field.name() == "message" {
let _ = write!(self.out, "{value:?}");
self.wrote_message = true;
} else {
let _ = write!(
self.out,
"{}{}={value:?}",
if self.wrote_message { " " } else { "" },
field.name()
);
self.wrote_message = true;
}
}
}
fn render_event(event: &tracing::Event<'_>) -> String {
let meta = event.metadata();
let mut line = ConsoleLine {
out: format!("{:<5} {}: ", meta.level(), meta.target()),
wrote_message: false,
};
event.record(&mut line);
line.out
}
impl tracing::Subscriber for ConsoleSubscriber {
fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
*metadata.level() <= tracing::Level::INFO
}
fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
Some(tracing::level_filters::LevelFilter::INFO)
}
fn event(&self, event: &tracing::Event<'_>) {
eprintln!("{}", render_event(event));
}
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
fn enter(&self, _span: &tracing::span::Id) {}
fn exit(&self, _span: &tracing::span::Id) {}
}
pub fn install_console_subscriber() -> bool {
tracing::subscriber::set_global_default(ConsoleSubscriber).is_ok()
}
static PANIC_HOOK_INSTALLED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn panic_announcement(thread: Option<&str>, location: &str, payload: &str) -> String {
let thread = thread.unwrap_or("<unnamed>");
let consequence = if thread == "main" {
"the IOC's entry thread is unwinding: the image is going down, and every \
connection it serves with it"
} else {
"that thread is gone and nothing restarts it; the IOC keeps listening and \
keeps answering searches, so from outside it still looks healthy"
};
format!("panic on thread `{thread}` at {location}: {payload} -- {consequence}")
}
fn panic_payload(info: &std::panic::PanicHookInfo<'_>) -> String {
if let Some(s) = info.payload().downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"<non-string panic payload>".to_string()
}
}
pub fn install_panic_hook() -> bool {
use std::sync::atomic::Ordering as AtomicOrdering;
if PANIC_HOOK_INSTALLED.swap(true, AtomicOrdering::AcqRel) {
return false;
}
std::panic::set_hook(Box::new(|info| {
let location = match info.location() {
Some(l) => format!("{}:{}", l.file(), l.line()),
None => "an unknown location".to_string(),
};
let thread = std::thread::current();
errlog_sev_printf(
ErrlogSevEnum::Fatal,
&panic_announcement(thread.name(), &location, &panic_payload(info)),
);
}));
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[repr(u8)]
pub enum ErrlogSevEnum {
Info = 0,
Minor = 1,
Major = 2,
Fatal = 3,
}
impl ErrlogSevEnum {
pub fn as_str(self) -> &'static str {
match self {
ErrlogSevEnum::Info => "info",
ErrlogSevEnum::Minor => "minor",
ErrlogSevEnum::Major => "major",
ErrlogSevEnum::Fatal => "fatal",
}
}
fn from_u8(v: u8) -> ErrlogSevEnum {
match v {
0 => ErrlogSevEnum::Info,
1 => ErrlogSevEnum::Minor,
2 => ErrlogSevEnum::Major,
_ => ErrlogSevEnum::Fatal,
}
}
}
pub fn errlog_sev_enum_string(severity: ErrlogSevEnum) -> &'static str {
severity.as_str()
}
static SEV_TO_LOG: AtomicU8 = AtomicU8::new(ErrlogSevEnum::Minor as u8);
pub fn errlog_set_sev_to_log(severity: ErrlogSevEnum) {
SEV_TO_LOG.store(severity as u8, Ordering::Relaxed);
}
pub fn errlog_get_sev_to_log() -> ErrlogSevEnum {
ErrlogSevEnum::from_u8(SEV_TO_LOG.load(Ordering::Relaxed))
}
pub fn erl_warning() -> &'static str {
use std::io::IsTerminal;
let term_names_itself = std::env::var_os("TERM").is_some_and(|t| !t.is_empty());
if std::io::stderr().is_terminal() && term_names_itself {
"\x1b[35;1mWARNING\x1b[0m"
} else {
"WARNING"
}
}
pub fn errlog_sev_printf(severity: ErrlogSevEnum, message: &str) -> bool {
if severity < errlog_get_sev_to_log() {
return false;
}
let line = format!("sevr={} {}", severity.as_str(), message);
match severity {
ErrlogSevEnum::Info => {
tracing::info!(target: "epics_base_rs::errlog", "{line}")
}
ErrlogSevEnum::Minor => {
tracing::warn!(target: "epics_base_rs::errlog", "{line}")
}
ErrlogSevEnum::Major | ErrlogSevEnum::Fatal => {
tracing::error!(target: "epics_base_rs::errlog", "{line}")
}
}
console_fallback(&format_args!("{line}"));
true
}
pub fn errlog_printf(message: &str) {
tracing::info!(target: "epics_base_rs::errlog", "{message}");
console_fallback(&format_args!("{message}"));
}
#[macro_export]
macro_rules! rt_debug {
($($arg:tt)*) => {
::tracing::debug!(target: "epics_base_rs::runtime", "{}", format!($($arg)*));
};
}
#[macro_export]
macro_rules! rt_info {
($($arg:tt)*) => {
::tracing::info!(target: "epics_base_rs::runtime", "{}", format!($($arg)*));
};
}
#[macro_export]
macro_rules! rt_warn {
($($arg:tt)*) => {
::tracing::warn!(target: "epics_base_rs::runtime", "{}", format!($($arg)*));
};
}
#[macro_export]
macro_rules! rt_error {
($($arg:tt)*) => {
::tracing::error!(target: "epics_base_rs::runtime", "{}", format!($($arg)*));
};
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn test_log_macros_compile() {
rt_debug!("debug message {}", 42);
rt_info!("info message");
rt_warn!("warn: {}", "something");
rt_error!("error: {} {}", "bad", "thing");
}
#[test]
#[serial]
fn with_no_subscriber_nothing_is_listening() {
assert!(
nothing_is_listening(),
"the test process has no global subscriber, so errlog output is \
being discarded and the console fallback must engage"
);
}
#[test]
#[serial]
fn with_a_subscriber_the_fallback_stands_down() {
use tracing::subscriber::with_default;
let captured = with_default(tracing_subscriber::registry(), nothing_is_listening);
assert!(
!captured,
"a scoped subscriber is listening, so the console fallback must not fire"
);
}
#[test]
#[serial]
fn the_console_subscriber_declares_itself_to_the_dispatcher() {
use tracing::level_filters::LevelFilter;
use tracing::subscriber::with_default;
let (still_mute, level) = with_default(ConsoleSubscriber, || {
(nothing_is_listening(), LevelFilter::current())
});
assert!(
!still_mute,
"the console subscriber is listening, so the errlog fallback must \
not also print — that is every line twice"
);
assert_eq!(
level,
LevelFilter::INFO,
"the console takes INFO and above, matching a C IOC's errlog console"
);
}
struct CapturingSubscriber(std::sync::Arc<std::sync::Mutex<Vec<String>>>);
impl tracing::Subscriber for CapturingSubscriber {
fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
*metadata.level() <= tracing::Level::INFO
}
fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
Some(tracing::level_filters::LevelFilter::INFO)
}
fn event(&self, event: &tracing::Event<'_>) {
self.0.lock().expect("sink").push(render_event(event));
}
fn new_span(&self, _s: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _s: &tracing::span::Id, _v: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _s: &tracing::span::Id, _f: &tracing::span::Id) {}
fn enter(&self, _s: &tracing::span::Id) {}
fn exit(&self, _s: &tracing::span::Id) {}
}
#[test]
#[serial]
fn the_console_line_carries_message_and_fields() {
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
tracing::subscriber::with_default(CapturingSubscriber(seen.clone()), || {
tracing::warn!(target: "epics_base_rs::test", nth = 7, "refused a client");
tracing::debug!(target: "epics_base_rs::test", "not at console level");
});
let lines = seen.lock().expect("sink").clone();
assert_eq!(
lines,
vec!["WARN epics_base_rs::test: refused a client nth=7".to_string()],
"one INFO-or-above event, message unquoted, fields appended"
);
}
#[test]
#[serial]
fn the_console_subscriber_declines_below_info() {
use tracing::level_filters::LevelFilter;
let taken = tracing::subscriber::with_default(ConsoleSubscriber, || {
LevelFilter::current() >= LevelFilter::DEBUG
});
assert!(!taken, "DEBUG must be below the console's level");
}
#[test]
fn sev_enum_strings_match_c() {
assert_eq!(errlog_sev_enum_string(ErrlogSevEnum::Info), "info");
assert_eq!(errlog_sev_enum_string(ErrlogSevEnum::Minor), "minor");
assert_eq!(errlog_sev_enum_string(ErrlogSevEnum::Major), "major");
assert_eq!(errlog_sev_enum_string(ErrlogSevEnum::Fatal), "fatal");
}
#[test]
fn sev_enum_ordering() {
assert!(ErrlogSevEnum::Info < ErrlogSevEnum::Minor);
assert!(ErrlogSevEnum::Minor < ErrlogSevEnum::Major);
assert!(ErrlogSevEnum::Major < ErrlogSevEnum::Fatal);
}
#[test]
#[serial(errlog_sev)]
fn sev_to_log_threshold_roundtrips() {
errlog_set_sev_to_log(ErrlogSevEnum::Major);
assert_eq!(errlog_get_sev_to_log(), ErrlogSevEnum::Major);
errlog_set_sev_to_log(ErrlogSevEnum::Minor);
assert_eq!(errlog_get_sev_to_log(), ErrlogSevEnum::Minor);
}
#[test]
#[serial(errlog_sev)]
fn sev_printf_suppresses_below_threshold() {
errlog_set_sev_to_log(ErrlogSevEnum::Major);
assert!(!errlog_sev_printf(ErrlogSevEnum::Info, "quiet"));
assert!(!errlog_sev_printf(ErrlogSevEnum::Minor, "quiet"));
assert!(errlog_sev_printf(ErrlogSevEnum::Major, "loud"));
assert!(errlog_sev_printf(ErrlogSevEnum::Fatal, "loud"));
errlog_set_sev_to_log(ErrlogSevEnum::Minor);
}
#[test]
fn a_worker_panic_says_the_ioc_is_still_up_and_no_longer_whole() {
let line = panic_announcement(
Some("CAS-client-3"),
"blocking.rs:412",
"index out of bounds",
);
assert!(line.contains("CAS-client-3"), "{line}");
assert!(line.contains("blocking.rs:412"), "{line}");
assert!(line.contains("index out of bounds"), "{line}");
assert!(
line.contains("keeps listening"),
"a worker panic must say the IOC survives it, or the console reads \
like the IOC died when it did not: {line}"
);
assert!(
!line.contains("going down"),
"a worker panic must not claim the image is finished: {line}"
);
}
#[test]
fn an_entry_thread_panic_says_the_image_is_finished() {
let line = panic_announcement(Some("main"), "rtems-ca-ioc.rs:118", "iocInit failed");
assert!(
line.contains("going down"),
"a panic out of the entry thread ends the image, and the console is \
the only place that can say so: {line}"
);
assert!(!line.contains("keeps listening"), "{line}");
}
#[test]
fn an_unnamed_thread_is_still_named_something() {
let line = panic_announcement(None, "x.rs:1", "boom");
assert!(line.contains("<unnamed>"), "{line}");
assert!(
line.contains("keeps listening"),
"an unnamed thread is not the entry thread — std names that one \
`main` — so it takes the worker consequence: {line}"
);
}
#[test]
#[serial]
fn the_panic_hook_installs_once() {
assert!(install_panic_hook(), "the first install takes effect");
assert!(
!install_panic_hook(),
"a second install must be refused, not chained onto the first"
);
let _ = std::panic::take_hook();
}
#[test]
#[serial]
fn the_panic_hook_does_not_run_the_hook_it_replaced() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
let previous_ran = Arc::new(AtomicBool::new(false));
let flag = previous_ran.clone();
std::panic::set_hook(Box::new(move |_| {
flag.store(true, AtomicOrdering::SeqCst);
}));
assert!(install_panic_hook(), "the install takes effect");
let caught = std::panic::catch_unwind(|| panic!("a panic the hook must report once"));
assert!(caught.is_err(), "the panic was raised");
assert!(
!previous_ran.load(AtomicOrdering::SeqCst),
"the replaced hook must not run: chaining it doubles the console \
output and appends a RUST_BACKTRACE note that cannot be acted on"
);
let _ = std::panic::take_hook();
}
}
pub fn single_line(record: &str) -> std::borrow::Cow<'_, str> {
fn must_escape(c: char) -> bool {
(c as u32) < 0x20 || c as u32 == 0x7F
}
if !record.contains(must_escape) {
return std::borrow::Cow::Borrowed(record);
}
let mut out = String::with_capacity(record.len() + 8);
for c in record.chars() {
if must_escape(c) {
use std::fmt::Write;
let _ = write!(out, "\\x{:02x}", c as u32);
} else {
out.push(c);
}
}
std::borrow::Cow::Owned(out)
}
#[cfg(test)]
mod single_line_tests {
use super::single_line;
#[test]
fn no_input_can_produce_more_than_one_line() {
for b in 0u8..=0x7F {
let c = b as char;
let record = format!("a{c}b");
let out = single_line(&record);
assert_eq!(
out.lines().count().max(1),
1,
"byte {b:#04x} split the record: {out:?}"
);
assert!(!out.contains('\n'), "byte {b:#04x} left a newline");
assert!(!out.contains('\r'), "byte {b:#04x} left a carriage return");
assert!(!out.contains('\0'), "byte {b:#04x} left a NUL");
}
}
#[test]
fn exactly_the_ascii_control_range_is_escaped() {
for b in 0u8..=0xFF {
if b >= 0x80 {
continue; }
let c = b as char;
let raw = c.to_string();
let out = single_line(&raw);
let escaped = out != raw;
assert_eq!(
escaped,
b < 0x20 || b == 0x7F,
"byte {b:#04x}: escaped={escaped}, expected={}",
b < 0x20 || b == 0x7F
);
}
assert_eq!(single_line("\n"), "\\x0a");
assert_eq!(single_line("\r"), "\\x0d");
assert_eq!(single_line("\0"), "\\x00");
assert_eq!(single_line("\u{7f}"), "\\x7f");
}
#[test]
fn a_clean_record_is_passed_through_without_allocating() {
let clean = "Apr 09 14:35:21 alice@opi-1 TEMP:setpoint 3.14 old=? OK";
assert!(matches!(single_line(clean), std::borrow::Cow::Borrowed(_)));
assert_eq!(single_line(clean), clean);
assert_eq!(single_line("설정값 μm"), "설정값 μm");
}
#[test]
fn it_is_a_no_op_on_already_escaped_output() {
let json = r#"{"user":"a\nb","pv":"X"}"#;
assert_eq!(single_line(json), json);
assert_eq!(single_line(&single_line("a\nb")), single_line("a\nb"));
}
}