Files
kaptan/firmware/libs/kaptan-log/src/facade.rs
T

549 lines
18 KiB
Rust

//! Optional standard [`log`] facade backed by single-attempt atomic ingresses.
//!
//! The ingress never allocates, masks interrupts, locks, spins, encodes
//! MAVLink, or accesses DMA. Flight images keep this feature disabled; a
//! future normal-task image must reject ISR/panic paths before formatting.
use core::{
cell::UnsafeCell,
sync::atomic::{AtomicBool, Ordering},
};
use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError};
use crate::{ErrorAdditionalInformation, ErrorPath, LogFlags, LogRecord, LogText, Severity};
/// Saturating, process-local accounting for the optional global facade.
///
/// It is deliberately not serialized in a Kaptan diagnostic message; the
/// facade remains a small text ingress rather than a metrics protocol.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct FacadeHealth {
/// Calls rejected before formatting because their context is forbidden.
pub forbidden_context: u32,
/// Calls that lost the one-attempt ingress ownership race.
pub contended: u32,
/// Calls dropped because their bounded ingress lane was full.
pub full: u32,
/// Formatted messages truncated to the fixed text capacity.
pub truncated: u32,
}
struct FacadeCounters {
forbidden_context: core::sync::atomic::AtomicU32,
contended: core::sync::atomic::AtomicU32,
full: core::sync::atomic::AtomicU32,
truncated: core::sync::atomic::AtomicU32,
}
impl FacadeCounters {
const fn new() -> Self {
Self {
forbidden_context: core::sync::atomic::AtomicU32::new(0),
contended: core::sync::atomic::AtomicU32::new(0),
full: core::sync::atomic::AtomicU32::new(0),
truncated: core::sync::atomic::AtomicU32::new(0),
}
}
/// A single compare-exchange keeps this diagnostic counter non-wrapping
/// without a retry loop. Under simultaneous facade callers it may
/// undercount, but it never blocks or falsely wraps to zero.
fn increment(counter: &core::sync::atomic::AtomicU32) {
let current = counter.load(Ordering::Relaxed);
if current != u32::MAX {
let _ = counter.compare_exchange(
current,
current + 1,
Ordering::Relaxed,
Ordering::Relaxed,
);
}
}
fn snapshot(&self) -> FacadeHealth {
FacadeHealth {
forbidden_context: self.forbidden_context.load(Ordering::Relaxed),
contended: self.contended.load(Ordering::Relaxed),
full: self.full.load(Ordering::Relaxed),
truncated: self.truncated.load(Ordering::Relaxed),
}
}
}
#[derive(Clone, Copy)]
struct IngressRecord {
severity: Severity,
flags: LogFlags,
body: IngressBody,
}
#[derive(Clone, Copy)]
enum IngressBody {
Log {
message: LogText,
},
Error {
path: ErrorPath,
additional_information: ErrorAdditionalInformation,
},
}
impl IngressBody {
const fn is_truncated(self) -> bool {
match self {
Self::Log { message } => message.is_truncated(),
Self::Error {
path,
additional_information,
} => path.is_truncated() || additional_information.is_truncated(),
}
}
}
struct IngressQueue<const N: usize> {
entries: [Option<IngressRecord>; N],
head: usize,
len: usize,
}
impl<const N: usize> IngressQueue<N> {
const fn new() -> Self {
assert!(N > 0, "facade ingress must be nonempty");
Self {
entries: [None; N],
head: 0,
len: 0,
}
}
fn push(&mut self, record: IngressRecord) -> bool {
if self.len == N {
return false;
}
let index = (self.head + self.len) % N;
self.entries[index] = Some(record);
self.len += 1;
true
}
fn pop(&mut self) -> Option<IngressRecord> {
if self.len == 0 {
return None;
}
let record = self.entries[self.head].take();
self.head = (self.head + 1) % N;
self.len -= 1;
record
}
}
/// Fixed global-facade ingress. The normal task/runtime owner takes each
/// transport-neutral record and fans it out to independently filtered sinks.
pub struct FacadeIngress<const N: usize> {
/// One dedicated critical slot keeps error records independent from the
/// bounded ordinary FIFO. It is deliberately one slot: producers never
/// spin, and a later error may be dropped only when an earlier error is
/// already retained.
critical: UnsafeCell<Option<IngressRecord>>,
critical_busy: AtomicBool,
queue: UnsafeCell<IngressQueue<N>>,
queue_busy: AtomicBool,
counters: FacadeCounters,
}
// SAFETY: each interior-mutability cell is accessed only while its matching
// atomic busy bit is acquired by one compare-exchange. The operation never
// waits or retries; contention leaves the corresponding bounded lane intact.
unsafe impl<const N: usize> Sync for FacadeIngress<N> {}
impl<const N: usize> FacadeIngress<N> {
/// Creates an empty fixed ingress.
pub const fn new() -> Self {
Self {
critical: UnsafeCell::new(None),
critical_busy: AtomicBool::new(false),
queue: UnsafeCell::new(IngressQueue::new()),
queue_busy: AtomicBool::new(false),
counters: FacadeCounters::new(),
}
}
fn push(&self, record: IngressRecord) {
if record.severity <= Severity::Error {
self.push_critical(record);
return;
}
match self.with_queue(|queue| queue.push(record)) {
Some(true) => {}
Some(false) => FacadeCounters::increment(&self.counters.full),
None => FacadeCounters::increment(&self.counters.contended),
}
}
fn push_critical(&self, record: IngressRecord) {
match self.with_critical(|slot| {
if slot.is_none() {
*slot = Some(record);
true
} else {
false
}
}) {
Some(true) => {}
Some(false) => FacadeCounters::increment(&self.counters.full),
None => FacadeCounters::increment(&self.counters.contended),
}
}
/// Process-local bounded-ingress accounting.
pub fn health(&self) -> FacadeHealth {
self.counters.snapshot()
}
/// Takes at most one preformatted event for composition-level fan-out.
///
/// The ingress deliberately applies no sink policy. A caller may submit
/// the returned copy to MAVLink, blackbox, or any later sink without one
/// sink's verbosity selection suppressing another's event.
pub fn take(&self) -> Option<LogRecord> {
let record = match self.with_critical(Option::take) {
Some(Some(record)) => record,
Some(None) => self.with_queue(IngressQueue::pop).flatten()?,
None => return None,
};
Some(match record.body {
IngressBody::Log { message } => LogRecord::new(record.severity, record.flags, message),
IngressBody::Error {
path,
additional_information,
} => LogRecord::error(record.flags, path, additional_information),
})
}
fn with_critical<R>(
&self,
operation: impl FnOnce(&mut Option<IngressRecord>) -> R,
) -> Option<R> {
if self
.critical_busy
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
return None;
}
// SAFETY: `critical_busy` grants exclusive access to the one fixed
// critical slot. No call to `operation` escapes its mutable reference.
let result = operation(unsafe { &mut *self.critical.get() });
self.critical_busy.store(false, Ordering::Release);
Some(result)
}
fn with_queue<R>(&self, operation: impl FnOnce(&mut IngressQueue<N>) -> R) -> Option<R> {
if self
.queue_busy
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
return None;
}
// SAFETY: `queue_busy` grants this caller exclusive access. No call
// to `operation` escapes the mutable reference and it only performs
// fixed-array queue work.
let result = operation(unsafe { &mut *self.queue.get() });
self.queue_busy.store(false, Ordering::Release);
Some(result)
}
}
impl<const N: usize> Default for FacadeIngress<N> {
fn default() -> Self {
Self::new()
}
}
/// Installed standard logger. The context probe must return true for
/// ISR/panic/hard-fault/shutdown contexts; those calls are rejected before
/// [`Record::args`] is formatted.
pub struct GlobalLogger<const N: usize> {
ingress: &'static FacadeIngress<N>,
forbidden_context: fn() -> bool,
}
impl<const N: usize> GlobalLogger<N> {
/// Creates a logger tied to one static bounded ingress.
pub const fn new(ingress: &'static FacadeIngress<N>, forbidden_context: fn() -> bool) -> Self {
Self {
ingress,
forbidden_context,
}
}
/// Installs this logger once for the process/image and enables all Rust
/// levels compiled into the image. Each sink applies its policy after it
/// receives a record from the ingress.
pub fn install(&'static self) -> Result<(), SetLoggerError> {
log::set_logger(self)?;
log::set_max_level(LevelFilter::Trace);
Ok(())
}
}
impl<const N: usize> Log for GlobalLogger<N> {
fn enabled(&self, _: &Metadata<'_>) -> bool {
if (self.forbidden_context)() {
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
return false;
}
true
}
fn log(&self, record: &Record<'_>) {
if (self.forbidden_context)() {
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
return;
}
let severity = severity(record.level());
let flags = if record.level() == Level::Trace {
LogFlags::TRACE
} else {
LogFlags::NONE
};
let body = if severity <= Severity::Error {
IngressBody::Error {
path: ErrorPath::from_message(record.target()),
additional_information: ErrorAdditionalInformation::from_arguments(format_args!(
"{}",
record.args()
)),
}
} else {
IngressBody::Log {
message: LogText::from_arguments(format_args!(
"[{}] {}",
record.target(),
record.args()
)),
}
};
if body.is_truncated() {
FacadeCounters::increment(&self.ingress.counters.truncated);
}
self.ingress.push(IngressRecord {
severity,
flags,
body,
});
}
fn flush(&self) {}
}
const fn severity(level: Level) -> Severity {
match level {
Level::Error => Severity::Error,
Level::Warn => Severity::Warning,
Level::Info => Severity::Info,
Level::Debug | Level::Trace => Severity::Debug,
}
}
#[cfg(test)]
mod tests {
use core::fmt;
use std::sync::Once;
use super::{FacadeIngress, GlobalLogger};
use crate::{RecordChannel, Severity};
use log::{Level, Log, Metadata, Record};
static GLOBAL_INGRESS: FacadeIngress<2> = FacadeIngress::new();
static GLOBAL_LOGGER: GlobalLogger<2> = GlobalLogger::new(&GLOBAL_INGRESS, never_forbidden);
static INSTALL_GLOBAL: Once = Once::new();
fn never_forbidden() -> bool {
false
}
fn always_forbidden() -> bool {
true
}
#[test]
fn rejects_forbidden_context_before_formatting() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, always_forbidden);
let metadata = Metadata::builder()
.level(Level::Error)
.target("test")
.build();
assert!(!logger.enabled(&metadata));
assert_eq!(INGRESS.health().forbidden_context, 1);
static UNFILTERED: FacadeIngress<2> = FacadeIngress::new();
let unfiltered_logger = GlobalLogger::new(&UNFILTERED, never_forbidden);
let debug = Metadata::builder()
.level(Level::Debug)
.target("test")
.build();
assert!(unfiltered_logger.enabled(&debug));
unfiltered_logger.log(
&Record::builder()
.metadata(debug)
.args(format_args!("sink decides whether to keep this"))
.build(),
);
assert_eq!(
UNFILTERED
.take()
.expect("debug event reaches the unfiltered ingress")
.severity(),
Severity::Debug
);
}
#[test]
fn formats_once_then_exposes_a_transport_neutral_record() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
let record = Record::builder()
.metadata(
Metadata::builder()
.level(Level::Error)
.target("test")
.build(),
)
.args(format_args!("link failed"))
.build();
logger.log(&record);
let pending = INGRESS.take().expect("facade event retained");
assert_eq!(pending.error_path().unwrap().as_str(), "test");
assert_eq!(
pending.additional_information().unwrap().as_str(),
"link failed"
);
assert_eq!(pending.severity(), Severity::Error);
assert_eq!(pending.channel(), RecordChannel::Error);
}
struct MustNotFormat;
impl fmt::Display for MustNotFormat {
fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result {
panic!("forbidden-context log arguments must not format")
}
}
#[test]
fn standard_log_macros_enqueue_bounded_targeted_text() {
INSTALL_GLOBAL.call_once(|| GLOBAL_LOGGER.install().expect("one global test logger"));
log::info!("armed configuration loaded");
assert!(
GLOBAL_INGRESS
.take()
.expect("macro event retained")
.message()
.unwrap()
.as_str()
.ends_with("armed configuration loaded")
);
}
#[test]
fn forbidden_context_skips_argument_formatting() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, always_forbidden);
logger.log(
&Record::builder()
.metadata(
Metadata::builder()
.level(Level::Error)
.target("test")
.build(),
)
.args(format_args!("{}", MustNotFormat))
.build(),
);
assert_eq!(INGRESS.health().forbidden_context, 1);
}
#[test]
fn bounded_ingress_exposes_local_drop_and_truncation_accounting() {
static INGRESS: FacadeIngress<1> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
logger.log(
&Record::builder()
.metadata(
Metadata::builder()
.level(Level::Info)
.target("test")
.build(),
)
.args(format_args!("x"))
.build(),
);
logger.log(
&Record::builder()
.metadata(
Metadata::builder()
.level(Level::Info)
.target("test")
.build(),
)
.args(format_args!("y"))
.build(),
);
let long = "z".repeat(crate::MAXIMUM_LOG_TEXT_BYTES + 1);
logger.log(
&Record::builder()
.metadata(
Metadata::builder()
.level(Level::Info)
.target("test")
.build(),
)
.args(format_args!("{long}"))
.build(),
);
let health = INGRESS.health();
assert_eq!(health.full, 2);
assert_eq!(health.truncated, 1);
}
#[test]
fn full_optional_ingress_retains_an_error_for_the_error_channel() {
static INGRESS: FacadeIngress<1> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
logger.log(
&Record::builder()
.metadata(
Metadata::builder()
.level(Level::Info)
.target("test")
.build(),
)
.args(format_args!("optional"))
.build(),
);
logger.log(
&Record::builder()
.metadata(
Metadata::builder()
.level(Level::Error)
.target("test")
.build(),
)
.args(format_args!("critical"))
.build(),
);
let error = INGRESS
.take()
.expect("error retained ahead of optional text");
assert_eq!(error.channel(), RecordChannel::Error);
assert_eq!(error.error_path().unwrap().as_str(), "test");
assert_eq!(error.additional_information().unwrap().as_str(), "critical");
assert_eq!(INGRESS.health().full, 0);
}
}