feat(log): add bounded diagnostic transport

This commit is contained in:
Onur Özkan committed 2026-08-09 22:04:06 +03:00
1 parent 632bf9f01b
commit 0a75860841
46 files changed
+2758 -60

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
//! Retained crash-record codec primitives.
//!
//! This module defines and validates a fixed record only. It does not install a
//! panic handler, write retention memory, access flash, or transmit MAVLink.
use crate::KaptanError;
const MAGIC: u32 = u32::from_le_bytes(*b"KLOG");
const VERSION: u16 = 1;
const COMMITTED: u32 = u32::MAX;
/// Fixed endianness-independent retained record size.
pub const CRASH_RECORD_BYTES: usize = 32;
/// Stored reason for a previous abnormal or orderly runtime end.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum CrashKind {
/// A panic was captured by a future target-specific handler.
Panic = 1,
/// A hard fault was captured by a future target-specific handler.
HardFault = 2,
/// A requested shutdown sequence was captured.
Shutdown = 3,
/// A platform reset cause was captured.
Reset = 4,
}
impl CrashKind {
const fn as_u8(self) -> u8 {
self as u8
}
const fn from_u8(value: u8) -> Option<Self> {
match value {
1 => Some(Self::Panic),
2 => Some(Self::HardFault),
3 => Some(Self::Shutdown),
4 => Some(Self::Reset),
_ => None,
}
}
}
/// Validation failure for a retained record.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum CrashRecordError {
/// Magic/version/commit marker did not identify a completed record.
InvalidHeader,
/// Record payload did not match its checksum.
BadChecksum,
}
/// Fixed, commit-last retained record value.
///
/// A board integration writes every field except `commit`, writes `checksum`,
/// then writes `commit` last. This pure type only validates that protocol.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CrashRecord {
kind: CrashKind,
boot_id: u64,
detail: u64,
}
impl CrashRecord {
/// Creates one completed record for tests or a future retention backend.
pub fn new(kind: CrashKind, boot_id: u64, detail: u64) -> Self {
Self {
kind,
boot_id,
detail,
}
}
/// Encodes an endian-independent record. A target writes the final four
/// commit-marker bytes last; this function itself performs no I/O.
pub fn encode(self) -> [u8; CRASH_RECORD_BYTES] {
let mut bytes = [0_u8; CRASH_RECORD_BYTES];
bytes[0..4].copy_from_slice(&MAGIC.to_le_bytes());
bytes[4..6].copy_from_slice(&VERSION.to_le_bytes());
bytes[6] = self.kind.as_u8();
bytes[7] = 0;
bytes[8..16].copy_from_slice(&self.boot_id.to_le_bytes());
bytes[16..24].copy_from_slice(&self.detail.to_le_bytes());
let record_checksum = checksum(&bytes[..24]);
bytes[24..28].copy_from_slice(&record_checksum.to_le_bytes());
bytes[28..32].copy_from_slice(&COMMITTED.to_le_bytes());
bytes
}
/// Decodes and validates an explicitly little-endian retained record.
/// Arbitrary retained bytes are never reinterpreted as Rust enums.
pub fn decode(bytes: [u8; CRASH_RECORD_BYTES]) -> Result<Self, CrashRecordError> {
if u32::from_le_bytes(bytes[0..4].try_into().unwrap()) != MAGIC
|| u16::from_le_bytes(bytes[4..6].try_into().unwrap()) != VERSION
|| bytes[7] != 0
|| u32::from_le_bytes(bytes[28..32].try_into().unwrap()) != COMMITTED
{
return Err(CrashRecordError::InvalidHeader);
}
if u32::from_le_bytes(bytes[24..28].try_into().unwrap()) != checksum(&bytes[..24]) {
return Err(CrashRecordError::BadChecksum);
}
let Some(kind) = CrashKind::from_u8(bytes[6]) else {
return Err(CrashRecordError::InvalidHeader);
};
Ok(Self {
kind,
boot_id: u64::from_le_bytes(bytes[8..16].try_into().unwrap()),
detail: u64::from_le_bytes(bytes[16..24].try_into().unwrap()),
})
}
/// Captured end reason.
pub const fn kind(self) -> CrashKind {
self.kind
}
/// Board-local boot identity.
pub const fn boot_id(self) -> u64 {
self.boot_id
}
/// Target-defined compact panic/fault/reset detail.
pub const fn detail(self) -> u64 {
self.detail
}
}
const fn checksum(bytes: &[u8]) -> u32 {
let mut state = 0xffff_ffff_u32;
let mut index = 0;
while index < bytes.len() {
state = crc32c_byte(state, bytes[index]);
index += 1;
}
!state
}
const fn crc32c_byte(mut state: u32, byte: u8) -> u32 {
state ^= byte as u32;
let mut index = 0;
while index != 8 {
state = if state & 1 != 0 {
(state >> 1) ^ 0x82f6_3b78
} else {
state >> 1
};
index += 1;
}
state
}
#[cfg(test)]
mod tests {
use super::{CrashKind, CrashRecord, CrashRecordError};
use crate::ErrorReport;
#[test]
fn retained_record_rejects_torn_or_corrupt_payloads() {
let record = CrashRecord::new(CrashKind::Panic, 7, 11);
let bytes = record.encode();
assert_eq!(CrashRecord::decode(bytes), Ok(record));
let mut corrupt = bytes;
corrupt[24] ^= 1;
assert_eq!(
CrashRecord::decode(corrupt),
Err(CrashRecordError::BadChecksum)
);
let mut torn = bytes;
torn[28..32].copy_from_slice(&0_u32.to_le_bytes());
assert_eq!(
CrashRecord::decode(torn),
Err(CrashRecordError::InvalidHeader)
);
}
#[test]
fn validation_error_has_a_static_derived_message() {
assert_eq!(
CrashRecordError::BadChecksum.error_message().as_str(),
"kaptan_log::crash::CrashRecordError::BadChecksum"
);
}
}
+511
View File
@@ -0,0 +1,511 @@
//! 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, AtomicU8, Ordering},
};
use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError};
use crate::{EventHub, LogFilter, LogFlags, LogText, PublishOutcome, 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,
/// Optional records excluded by the active verbosity cap.
pub filtered: 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,
filtered: 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),
filtered: 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),
filtered: self.filtered.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,
text: LogText,
}
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 drains it into
/// an [`EventHub`] after startup.
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,
maximum_severity: AtomicU8,
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 empty fixed ingress with the boot-active verbosity cap.
pub const fn new(filter: LogFilter) -> Self {
Self {
critical: UnsafeCell::new(None),
critical_busy: AtomicBool::new(false),
queue: UnsafeCell::new(IngressQueue::new()),
queue_busy: AtomicBool::new(false),
maximum_severity: AtomicU8::new(filter.maximum_severity() as u8),
counters: FacadeCounters::new(),
}
}
/// Updates the runtime-selected maximum non-error verbosity.
pub fn set_filter(&self, filter: LogFilter) {
self.maximum_severity
.store(filter.maximum_severity() as u8, Ordering::Release);
}
fn allows(&self, severity: Severity) -> bool {
severity <= Severity::Error
|| (severity as u8) <= self.maximum_severity.load(Ordering::Acquire)
}
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()
}
/// Moves at most one preformatted facade record into the central hub.
pub fn drain_one_into<const CRITICAL: usize, const BEST_EFFORT: usize>(
&self,
hub: &mut EventHub<CRITICAL, BEST_EFFORT>,
) -> Option<PublishOutcome> {
let record = match self.with_critical(Option::take) {
Some(Some(record)) => record,
Some(None) => self.with_queue(IngressQueue::pop).flatten()?,
None => return None,
};
Some(hub.publish_text(record.severity, record.flags, record.text))
}
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)
}
}
/// 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. Kaptan's own filter applies afterward.
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: &Metadata<'_>) -> bool {
if (self.forbidden_context)() {
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
return false;
}
let Some(severity) = severity(metadata.level()) else {
return false;
};
if !self.ingress.allows(severity) {
FacadeCounters::increment(&self.ingress.counters.filtered);
return false;
}
true
}
fn log(&self, record: &Record<'_>) {
if (self.forbidden_context)() {
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
return;
}
let Some(severity) = severity(record.level()) else {
return;
};
if !self.ingress.allows(severity) {
FacadeCounters::increment(&self.ingress.counters.filtered);
return;
}
let flags = if record.level() == Level::Trace {
LogFlags::TRACE
} else {
LogFlags::NONE
};
let text = LogText::from_arguments(format_args!("[{}] {}", record.target(), record.args()));
if text.is_truncated() {
FacadeCounters::increment(&self.ingress.counters.truncated);
}
self.ingress.push(IngressRecord {
severity,
flags,
text,
});
}
fn flush(&self) {}
}
const fn severity(level: Level) -> Option<Severity> {
match level {
Level::Error => Some(Severity::Error),
Level::Warn => Some(Severity::Warning),
Level::Info => Some(Severity::Info),
Level::Debug | Level::Trace => Some(Severity::Debug),
}
}
#[cfg(test)]
mod tests {
use core::fmt;
use std::sync::Once;
use super::{FacadeIngress, GlobalLogger};
use crate::{EventHub, LogFilter, RecordChannel, Severity};
use log::{Level, Log, Metadata, Record};
static GLOBAL_INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
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(LogFilter::new(Severity::Debug));
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 FILTERED: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Info));
let filtered_logger = GlobalLogger::new(&FILTERED, never_forbidden);
let debug = Metadata::builder()
.level(Level::Debug)
.target("test")
.build();
assert!(!filtered_logger.enabled(&debug));
assert_eq!(FILTERED.health().filtered, 1);
}
#[test]
fn formats_once_then_drains_into_priority_hub() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
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 mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert!(INGRESS.drain_one_into(&mut hub).is_some());
let pending = hub.peek().unwrap();
assert_eq!(pending.message().as_str(), "[test] 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");
let mut hub = EventHub::<1, 2>::new(LogFilter::default());
assert!(GLOBAL_INGRESS.drain_one_into(&mut hub).is_some());
assert!(
hub.peek()
.unwrap()
.message()
.as_str()
.ends_with("armed configuration loaded")
);
}
#[test]
fn forbidden_context_skips_argument_formatting() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
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(LogFilter::new(Severity::Info));
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(LogFilter::new(Severity::Info));
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 mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert!(INGRESS.drain_one_into(&mut hub).is_some());
let error = hub.peek().expect("error retained ahead of optional text");
assert_eq!(error.channel(), RecordChannel::Error);
assert_eq!(error.message().as_str(), "[test] critical");
assert_eq!(INGRESS.health().full, 0);
}
}
+38
View File
@@ -0,0 +1,38 @@
//! Bounded log filtering policy.
use crate::Severity;
/// Immutable policy for best-effort log admission.
///
/// Error/critical/vital records are never filtered by this type; callers use
/// their dedicated delivery lanes for those records.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LogFilter {
maximum_severity: Severity,
}
impl LogFilter {
/// Creates a best-effort verbosity cap.
pub const fn new(maximum_severity: Severity) -> Self {
Self { maximum_severity }
}
/// The selected MAVLink-compatible severity cap.
///
/// The value is exact on the wire: in particular, `NOTICE` does not
/// silently permit `INFO` records.
pub const fn maximum_severity(self) -> Severity {
self.maximum_severity
}
/// Returns true if a best-effort record should be retained.
pub const fn allows(self, severity: Severity) -> bool {
(severity as u8) <= (self.maximum_severity as u8)
}
}
impl Default for LogFilter {
fn default() -> Self {
Self::new(Severity::Info)
}
}
+372
View File
@@ -0,0 +1,372 @@
//! Bounded priority handling for fixed human-readable diagnostic records.
use core::fmt;
use crate::{
DeliveryClass, ErrorReport, LogFilter, LogFlags, LogRecord, LogText, Severity,
StaticErrorMessage,
};
/// Outcome from one bounded diagnostic admission attempt.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PublishOutcome {
/// The record was retained.
Queued,
/// An adjacent equivalent record absorbed this occurrence.
Coalesced,
/// An ordinary log record was excluded by the configured verbosity cap.
Filtered,
/// A best-effort record was replaced by newer optional information.
ReplacedOldest,
/// The critical lane kept its earlier record.
RejectedFull,
}
/// Result returned by the sole telemetry owner after attempting to admit a
/// record to its lower transport.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransportAdmission {
/// The lower transport retained the record, so it may leave the hub.
Accepted,
/// The lower transport did not accept it yet; retain it for retry.
Retry,
/// The lower transport will not accept optional detail. This can only
/// discard a best-effort record; critical records remain queued.
DropBestEffort,
}
/// Saturating, process-local accounting for bounded diagnostic admission.
///
/// These counters intentionally are not part of `KAPTAN_LOG` or
/// `KAPTAN_ERROR`: neither wire message is a metrics protocol.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct HubHealth {
/// Optional records rejected by the active verbosity cap.
pub filtered: u32,
/// Adjacent records folded into an existing entry.
pub coalesced: u32,
/// Optional entries replaced because their bounded lane was full.
pub best_effort_replaced: u32,
/// Error entries rejected because their bounded critical lane was full.
pub critical_rejected: u32,
/// Optional entries the lower transport refused permanently.
pub best_effort_transport_dropped: u32,
}
impl HubHealth {
fn increment(value: &mut u32) {
*value = value.saturating_add(1);
}
}
struct Lane<const N: usize> {
entries: [Option<LogRecord>; N],
head: usize,
len: usize,
}
impl<const N: usize> Lane<N> {
const fn new() -> Self {
assert!(N > 0, "diagnostic lanes must be nonempty");
Self {
entries: [None; N],
head: 0,
len: 0,
}
}
fn publish(&mut self, record: LogRecord, replace_oldest: bool) -> PublishOutcome {
if self.len != 0 {
let tail = (self.head + self.len - 1) % N;
if let Some(previous) = self.entries[tail].as_mut()
&& previous.is_equivalent_to(&record)
{
previous.increment_occurrences();
return PublishOutcome::Coalesced;
}
}
if self.len == N {
if replace_oldest {
self.entries[self.head] = Some(record);
self.head = (self.head + 1) % N;
return PublishOutcome::ReplacedOldest;
}
return PublishOutcome::RejectedFull;
}
let index = (self.head + self.len) % N;
self.entries[index] = Some(record);
self.len += 1;
PublishOutcome::Queued
}
fn peek(&self) -> Option<LogRecord> {
self.entries.get(self.head).copied().flatten()
}
fn pop(&mut self) -> Option<LogRecord> {
if self.len == 0 {
return None;
}
let record = self.entries[self.head].take();
self.head = (self.head + 1) % N;
self.len -= 1;
record
}
}
/// One bounded priority hub. It owns no global logger, MAVLink state, or DMA.
pub struct EventHub<const CRITICAL: usize, const BEST_EFFORT: usize> {
filter: LogFilter,
critical: Lane<CRITICAL>,
best_effort: Lane<BEST_EFFORT>,
health: HubHealth,
}
impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EFFORT> {
/// Creates one hub with its immutable boot-active verbosity cap.
pub const fn new(filter: LogFilter) -> Self {
Self {
filter,
critical: Lane::new(),
best_effort: Lane::new(),
health: HubHealth {
filtered: 0,
coalesced: 0,
best_effort_replaced: 0,
critical_rejected: 0,
best_effort_transport_dropped: 0,
},
}
}
/// Immutable best-effort verbosity policy.
pub const fn filter(&self) -> LogFilter {
self.filter
}
/// Process-local bounded-admission accounting.
pub const fn health(&self) -> HubHealth {
self.health
}
/// Formats and retains one ordinary task/worker diagnostic. Error-level
/// severities are emitted through `KAPTAN_ERROR`; lower severities use
/// `KAPTAN_LOG`.
pub fn publish_arguments(
&mut self,
severity: Severity,
flags: LogFlags,
arguments: fmt::Arguments<'_>,
) -> PublishOutcome {
self.publish_text(severity, flags, LogText::from_arguments(arguments))
}
/// Retains one static derived error without formatting or exposing source
/// values. The adapter maps it to `KAPTAN_ERROR`.
pub fn publish_error<E: ErrorReport>(&mut self, flags: LogFlags, error: &E) -> PublishOutcome {
self.publish_error_static(flags, error.error_message())
}
/// Retains one static mailbox error. This is the ISR-safe counterpart to
/// [`Self::publish_error`].
pub fn publish_error_static(
&mut self,
flags: LogFlags,
message: &'static StaticErrorMessage,
) -> PublishOutcome {
self.publish_text(
Severity::Error,
flags,
LogText::from_message(message.text()),
)
}
/// Retains one preformatted bounded text payload. Error-or-higher
/// severities are independently retained and sent as `KAPTAN_ERROR`.
pub fn publish_text(
&mut self,
severity: Severity,
flags: LogFlags,
text: LogText,
) -> PublishOutcome {
let delivery = if severity <= Severity::Error {
DeliveryClass::Critical
} else {
DeliveryClass::BestEffort
};
if delivery == DeliveryClass::BestEffort && !self.filter.allows(severity) {
HubHealth::increment(&mut self.health.filtered);
return PublishOutcome::Filtered;
}
let record = LogRecord::new(severity, flags, text);
let outcome = match delivery {
DeliveryClass::Critical => self.critical.publish(record, false),
DeliveryClass::BestEffort => self.best_effort.publish(record, true),
};
match outcome {
PublishOutcome::Coalesced => HubHealth::increment(&mut self.health.coalesced),
PublishOutcome::ReplacedOldest => {
HubHealth::increment(&mut self.health.best_effort_replaced);
}
PublishOutcome::RejectedFull => {
HubHealth::increment(&mut self.health.critical_rejected);
}
PublishOutcome::Queued | PublishOutcome::Filtered => {}
}
outcome
}
/// Highest-priority record without relinquishing it.
pub fn peek(&self) -> Option<LogRecord> {
self.critical.peek().or_else(|| self.best_effort.peek())
}
/// Submits the current record through the sole telemetry owner. The hub
/// removes it only after `Accepted`, and only permits `DropBestEffort` for
/// optional detail. This keeps encoder/admission retry semantics local.
pub fn submit_next<R>(
&mut self,
submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R),
) -> Option<R> {
let record = self.peek()?;
let (admission, result) = submit(&record);
match admission {
TransportAdmission::Accepted => {
let _ = self.pop();
}
TransportAdmission::DropBestEffort
if record.delivery() == DeliveryClass::BestEffort =>
{
let _ = self.best_effort.pop();
HubHealth::increment(&mut self.health.best_effort_transport_dropped);
}
TransportAdmission::Retry | TransportAdmission::DropBestEffort => {}
}
Some(result)
}
fn pop(&mut self) -> Option<LogRecord> {
self.critical.pop().or_else(|| self.best_effort.pop())
}
}
#[cfg(test)]
mod tests {
use super::{EventHub, PublishOutcome, TransportAdmission};
use crate::{
DeliveryClass, LogFilter, LogFlags, LogText, RecordChannel, Severity, StaticErrorMessage,
};
#[test]
fn errors_bypass_filter_but_debug_does_not() {
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info));
assert_eq!(
hub.publish_text(
Severity::Debug,
LogFlags::NONE,
LogText::from_message("debug")
),
PublishOutcome::Filtered
);
assert_eq!(
hub.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("error")
),
PublishOutcome::Queued
);
let record = hub.peek().unwrap();
assert_eq!(record.message().as_str(), "error");
assert_eq!(record.channel(), RecordChannel::Error);
}
#[test]
fn static_messages_and_repeats_are_bounded() {
static FAULT: StaticErrorMessage = StaticErrorMessage::new("driver fault");
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued
);
assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Coalesced
);
let record = hub.peek().unwrap();
assert_eq!(record.occurrences, 2);
assert!(record.flags().contains(LogFlags::COALESCED));
}
#[test]
fn retry_retains_critical_and_only_best_effort_can_be_discarded() {
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
let _ = hub.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("fault"),
);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())),
Some(())
);
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::Critical);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::Accepted, ())),
Some(())
);
assert!(hub.peek().is_none());
let _ = hub.publish_text(
Severity::Info,
LogFlags::NONE,
LogText::from_message("detail"),
);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())),
Some(())
);
assert!(hub.peek().is_none());
assert_eq!(hub.health().best_effort_transport_dropped, 1);
}
#[test]
fn admission_losses_are_retained_as_local_saturating_accounting() {
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info));
let _ = hub.publish_text(
Severity::Debug,
LogFlags::NONE,
LogText::from_message("filtered"),
);
let _ = hub.publish_text(
Severity::Info,
LogFlags::NONE,
LogText::from_message("first optional"),
);
let _ = hub.publish_text(
Severity::Info,
LogFlags::NONE,
LogText::from_message("newer optional"),
);
let _ = hub.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("first error"),
);
let _ = hub.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("second error"),
);
assert_eq!(
hub.health(),
super::HubHealth {
filtered: 1,
coalesced: 0,
best_effort_replaced: 1,
critical_rejected: 1,
best_effort_transport_dropped: 0,
}
);
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Standard log levels and their MAVLink severity projection.
/// MAVLink-compatible severity. Numeric values deliberately match
/// `MAV_SEVERITY`.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
#[repr(u8)]
pub enum Severity {
/// System is unusable.
Emergency = 0,
/// Immediate action is required.
Alert = 1,
/// Critical safety or runtime failure.
Critical = 2,
/// Error-level failure.
Error = 3,
/// Recoverable warning.
Warning = 4,
/// Significant normal operation.
Notice = 5,
/// Informational operation.
Info = 6,
/// Diagnostic output.
Debug = 7,
}
impl Severity {
/// Converts the stable MAVLink numeric representation.
pub const fn try_from_wire(value: u8) -> Option<Self> {
match value {
0 => Some(Self::Emergency),
1 => Some(Self::Alert),
2 => Some(Self::Critical),
3 => Some(Self::Error),
4 => Some(Self::Warning),
5 => Some(Self::Notice),
6 => Some(Self::Info),
7 => Some(Self::Debug),
_ => None,
}
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Bounded, transport-neutral runtime diagnostics for Kaptan.
//!
//! This crate deliberately contains no MAVLink, board, filesystem, clock, or
//! configuration dependency. Flight code reports bounded text records to a
//! hub; a platform-owned adapter may later encode them for a link.
//! Reporting is observational and must never influence flight control or
//! actuator authority.
#![no_std]
extern crate self as kaptan_log;
#[cfg(test)]
extern crate std;
pub mod crash;
#[cfg(feature = "facade")]
pub mod facade;
pub mod filter;
pub mod hub;
pub mod level;
pub mod mailbox;
pub mod report;
pub mod shutdown;
pub mod text;
pub use crash::{CrashKind, CrashRecord, CrashRecordError};
pub use filter::LogFilter;
pub use hub::{EventHub, HubHealth, PublishOutcome, TransportAdmission};
pub use kaptan_log_derive::KaptanError;
pub use level::Severity;
pub use mailbox::StaticErrorMailbox;
pub use report::{
DeliveryClass, ErrorReport, LogFlags, LogRecord, RecordChannel, StaticErrorMessage,
};
pub use shutdown::{ShutdownReason, ShutdownReport};
pub use text::{LogText, MAXIMUM_LOG_TEXT_BYTES};
#[cfg(feature = "facade")]
pub use facade::FacadeHealth;
#[cfg(feature = "facade")]
pub use facade::{FacadeIngress, GlobalLogger};
/// Re-export of the standard Rust logging facade when an image opts into the
/// bounded atomic ingress.
#[cfg(feature = "facade")]
pub use log;
+97
View File
@@ -0,0 +1,97 @@
//! Fixed atomic handoff for static derived error messages.
use core::{
ptr,
sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering},
};
use crate::StaticErrorMessage;
/// One source-local, single-attempt static-fault mailbox.
///
/// Producer contexts retain the first pending message and mark later ones as
/// coalesced. They never loop, format, allocate, lock, or encode a frame. A
/// worker drains the exact message pointer and flag later.
pub struct StaticErrorMailbox {
message: AtomicPtr<StaticErrorMessage>,
coalesced: AtomicBool,
coalesced_total: AtomicU32,
}
impl StaticErrorMailbox {
/// Creates an empty mailbox.
pub const fn new() -> Self {
Self {
message: AtomicPtr::new(ptr::null_mut()),
coalesced: AtomicBool::new(false),
coalesced_total: AtomicU32::new(0),
}
}
/// Records one static message with one bounded atomic attempt.
pub fn record(&self, message: &'static StaticErrorMessage) {
if self
.message
.compare_exchange(
ptr::null_mut(),
core::ptr::from_ref(message).cast_mut(),
Ordering::AcqRel,
Ordering::Acquire,
)
.is_err()
{
self.coalesced.store(true, Ordering::Relaxed);
increment_saturating(&self.coalesced_total);
}
}
/// Removes the retained first message and its coalesced marker.
pub fn take(&self) -> (Option<&'static StaticErrorMessage>, bool) {
let message = self.message.swap(ptr::null_mut(), Ordering::AcqRel);
let coalesced = self.coalesced.swap(false, Ordering::AcqRel);
// SAFETY: `record` stores only pointers to static messages and the
// atomic swap transfers the exact pointer to this one worker.
let message = unsafe { message.as_ref() };
(message, coalesced)
}
/// Total later-fault coalesces since startup. This is local producer
/// accounting; wire diagnostics remain human-facing envelopes.
pub fn coalesced_total(&self) -> u32 {
self.coalesced_total.load(Ordering::Relaxed)
}
}
/// One bounded atomic increment: diagnostics may undercount a simultaneous
/// producer collision but never loop, block, or wrap back to zero.
fn increment_saturating(counter: &AtomicU32) {
let current = counter.load(Ordering::Relaxed);
if current != u32::MAX {
let _ =
counter.compare_exchange(current, current + 1, Ordering::Relaxed, Ordering::Relaxed);
}
}
impl Default for StaticErrorMailbox {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::StaticErrorMailbox;
use crate::StaticErrorMessage;
#[test]
fn source_mailbox_keeps_the_first_static_message_and_marks_later_faults() {
static FIRST: StaticErrorMessage = StaticErrorMessage::new("first");
static SECOND: StaticErrorMessage = StaticErrorMessage::new("second");
let mailbox = StaticErrorMailbox::new();
mailbox.record(&FIRST);
mailbox.record(&SECOND);
assert_eq!(mailbox.take(), (Some(&FIRST), true));
assert_eq!(mailbox.coalesced_total(), 1);
assert_eq!(mailbox.take(), (None, false));
}
}
+170
View File
@@ -0,0 +1,170 @@
//! Bounded human-readable diagnostic records.
use crate::{LogText, Severity};
/// Static message generated for one error type/variant.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StaticErrorMessage {
text: &'static str,
}
impl StaticErrorMessage {
/// Creates one compile-time error message.
pub const fn new(text: &'static str) -> Self {
Self { text }
}
/// Stable human-readable message text.
pub const fn text(self) -> &'static str {
self.text
}
/// Alias used by the error derive's [`core::fmt::Display`] implementation.
pub const fn as_str(self) -> &'static str {
self.text
}
}
/// Implemented by [`crate::KaptanError`] derives.
///
/// This trait is deliberately pure: obtaining a message never allocates,
/// publishes telemetry, changes safety state, or serializes generic sources.
pub trait ErrorReport {
/// Returns the static type/variant message for this error value.
fn error_message(&self) -> &'static StaticErrorMessage;
}
/// Bounded MAVLink log flags.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(transparent)]
pub struct LogFlags(u8);
impl LogFlags {
/// No optional flags.
pub const NONE: Self = Self(0);
/// Rust trace level, encoded as MAVLink DEBUG.
pub const TRACE: Self = Self(1 << 0);
/// Text did not fit the fixed record buffer.
pub const TRUNCATED: Self = Self(1 << 1);
/// Adjacent equivalent records were coalesced.
pub const COALESCED: Self = Self(1 << 2);
/// Raw MAVLink bitmask.
pub const fn bits(self) -> u8 {
self.0
}
/// Bits valid for an error-only record. Trace is a property of ordinary
/// `KAPTAN_LOG` diagnostics and is intentionally never emitted on
/// `KAPTAN_ERROR`.
pub const fn error_bits(self) -> u8 {
self.0 & (Self::TRUNCATED.0 | Self::COALESCED.0)
}
/// Adds a bit.
pub const fn with(self, other: Self) -> Self {
Self(self.0 | other.0)
}
/// Tests whether a bit is present.
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
}
/// Wire family selected for one diagnostic record.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RecordChannel {
/// An ordinary severity-and-text `KAPTAN_LOG` record.
Log,
/// An error-only `KAPTAN_ERROR` record.
Error,
}
/// Priority selected internally from a diagnostic level.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DeliveryClass {
/// Error logs retained independently from optional log verbosity.
Critical,
/// Warning/info/debug/trace logs subject to the configured filter.
BestEffort,
}
/// A fully formatted bounded diagnostic waiting for MAVLink admission.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LogRecord {
severity: Severity,
flags: LogFlags,
message: LogText,
channel: RecordChannel,
/// Adjacent identical messages retained as one record.
pub occurrences: u32,
delivery: DeliveryClass,
}
impl LogRecord {
/// Creates one bounded record. Error-or-higher severities use
/// [`RecordChannel::Error`]; ordinary severity records use
/// [`RecordChannel::Log`].
pub fn new(severity: Severity, mut flags: LogFlags, message: LogText) -> Self {
if message.is_truncated() {
flags = flags.with(LogFlags::TRUNCATED);
}
Self {
severity,
flags,
message,
channel: if severity <= Severity::Error {
RecordChannel::Error
} else {
RecordChannel::Log
},
occurrences: 1,
delivery: if severity <= Severity::Error {
DeliveryClass::Critical
} else {
DeliveryClass::BestEffort
},
}
}
/// Wire-compatible severity.
pub const fn severity(&self) -> Severity {
self.severity
}
/// Wire flag bitmask.
pub const fn flags(&self) -> LogFlags {
self.flags
}
/// Fixed formatted text.
pub const fn message(&self) -> &LogText {
&self.message
}
/// Custom MAVLink diagnostic family selected for this record.
pub const fn channel(&self) -> RecordChannel {
self.channel
}
/// Internal admission priority.
pub const fn delivery(&self) -> DeliveryClass {
self.delivery
}
/// Tests whether two pending records can coalesce without hiding a new
/// message, severity, or delivery class.
pub fn is_equivalent_to(&self, other: &Self) -> bool {
self.channel == other.channel
&& self.severity == other.severity
&& self.flags == other.flags
&& self.message == other.message
&& self.delivery == other.delivery
}
pub(crate) fn increment_occurrences(&mut self) {
self.occurrences = self.occurrences.saturating_add(1);
self.flags = self.flags.with(LogFlags::COALESCED);
}
}
+34
View File
@@ -0,0 +1,34 @@
//! Explicit shutdown taxonomy for a future platform-owned shutdown state machine.
/// Why an orderly output-inhibited shutdown completed.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum ShutdownReason {
/// An operator initiated the controlled stop.
Operator = 1,
/// Safety initiated the controlled stop.
Safety = 2,
}
/// One completed orderly firmware shutdown report.
///
/// This is a typed terminal-state observation from a future platform-owned
/// shutdown coordinator. It is emitted only after that coordinator has
/// inhibited outputs. It is deliberately not a shutdown request or a panic,
/// hard-fault, or reset report.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ShutdownReport {
reason: ShutdownReason,
}
impl ShutdownReport {
/// Creates one completed orderly shutdown report.
pub const fn new(reason: ShutdownReason) -> Self {
Self { reason }
}
/// Completed orderly shutdown reason.
pub const fn reason(self) -> ShutdownReason {
self.reason
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Fixed-capacity UTF-8 log text.
use core::fmt::{self, Write};
/// Maximum UTF-8 bytes carried by one Kaptan log record.
pub const MAXIMUM_LOG_TEXT_BYTES: usize = 160;
/// Bounded formatted text with an explicit truncation marker.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LogText {
bytes: [u8; MAXIMUM_LOG_TEXT_BYTES],
length: u8,
truncated: bool,
}
impl LogText {
/// Creates an empty bounded log message.
pub const fn new() -> Self {
Self {
bytes: [0; MAXIMUM_LOG_TEXT_BYTES],
length: 0,
truncated: false,
}
}
/// Formats one message into fixed storage without allocation.
pub fn from_arguments(arguments: fmt::Arguments<'_>) -> Self {
let mut text = Self::new();
let _ = fmt::write(&mut text, arguments);
text
}
/// Copies one UTF-8 message into fixed storage without splitting a code
/// point at the capacity boundary.
pub fn from_message(message: &str) -> Self {
let mut text = Self::new();
let _ = text.write_str(message);
text
}
/// Raw bounded wire bytes.
pub const fn bytes(&self) -> &[u8; MAXIMUM_LOG_TEXT_BYTES] {
&self.bytes
}
/// Number of meaningful UTF-8 bytes.
pub const fn len(&self) -> u8 {
self.length
}
/// Whether this record contains no message bytes.
pub const fn is_empty(&self) -> bool {
self.length == 0
}
/// Whether the original message exceeded bounded storage.
pub const fn is_truncated(&self) -> bool {
self.truncated
}
/// Returns the valid UTF-8 prefix.
pub fn as_str(&self) -> &str {
// `write_str` copies only valid UTF-8 code-point boundaries.
// SAFETY: all populated bytes originated from an input `&str`.
unsafe { core::str::from_utf8_unchecked(&self.bytes[..usize::from(self.length)]) }
}
}
impl Default for LogText {
fn default() -> Self {
Self::new()
}
}
impl fmt::Write for LogText {
fn write_str(&mut self, message: &str) -> fmt::Result {
let used = usize::from(self.length);
let remaining = MAXIMUM_LOG_TEXT_BYTES - used;
if message.len() <= remaining {
self.bytes[used..used + message.len()].copy_from_slice(message.as_bytes());
self.length = (used + message.len()) as u8;
return Ok(());
}
let prefix = message
.char_indices()
.take_while(|(index, character)| index + character.len_utf8() <= remaining)
.map(|(index, character)| index + character.len_utf8())
.last()
.unwrap_or(0);
self.bytes[used..used + prefix].copy_from_slice(&message.as_bytes()[..prefix]);
self.length = (used + prefix) as u8;
self.truncated = true;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{LogText, MAXIMUM_LOG_TEXT_BYTES};
#[test]
fn truncates_only_at_utf8_boundaries() {
let input = "x".repeat(MAXIMUM_LOG_TEXT_BYTES - 1) + "é";
let text = LogText::from_message(&input);
assert!(text.is_truncated());
assert_eq!(text.as_str().len(), MAXIMUM_LOG_TEXT_BYTES - 1);
assert!(core::str::from_utf8(&text.bytes()[..usize::from(text.len())]).is_ok());
}
}