refactor(firmware): simplify configuration and diagnostics
This commit is contained in:
1 parent
ba9c97ed3b
commit
fdf82a5114
96 files changed
+1612
-2772
No files matched your search
@@ -8,13 +8,5 @@ version.workspace = true
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Optional allocation-free standard-log ingress. It uses one bounded atomic
|
||||
# attempt rather than a critical section, so enabling it cannot select std.
|
||||
facade = ["dep:log"]
|
||||
|
||||
[dependencies]
|
||||
kaptan-config-derive = { path = "../kaptan-config-derive" }
|
||||
kaptan-log-derive = { path = "../kaptan-log-derive" }
|
||||
log = { version = "0.4.33", default-features = false, optional = true }
|
||||
@@ -1,184 +0,0 @@
|
||||
//! 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.static_error().path(),
|
||||
"kaptan_log::crash::CrashRecordError::BadChecksum"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,548 +1,231 @@
|
||||
//! 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.
|
||||
//! Fixed, allocation-free ingress used by the logging macros.
|
||||
|
||||
use core::{
|
||||
cell::UnsafeCell,
|
||||
fmt,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError};
|
||||
use crate::{Level, LogFlags, LogRecord, LogText};
|
||||
|
||||
use crate::{ErrorAdditionalInformation, ErrorPath, LogFlags, LogRecord, LogText, Severity};
|
||||
const GLOBAL_INGRESS_CAPACITY: usize = 2;
|
||||
static GLOBAL_INGRESS: LogIngress<GLOBAL_INGRESS_CAPACITY> = LogIngress::new();
|
||||
|
||||
/// 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 LogIngress<const N: usize> {
|
||||
entries: UnsafeCell<[Option<LogRecord>; N]>,
|
||||
head: UnsafeCell<usize>,
|
||||
len: UnsafeCell<usize>,
|
||||
busy: AtomicBool,
|
||||
}
|
||||
|
||||
struct FacadeCounters {
|
||||
forbidden_context: core::sync::atomic::AtomicU32,
|
||||
contended: core::sync::atomic::AtomicU32,
|
||||
full: core::sync::atomic::AtomicU32,
|
||||
truncated: core::sync::atomic::AtomicU32,
|
||||
}
|
||||
// SAFETY: mutable state is accessed only while `busy` grants exclusive access.
|
||||
// Producers and the consumer make one attempt and never wait or retry.
|
||||
unsafe impl<const N: usize> Sync for LogIngress<N> {}
|
||||
|
||||
impl FacadeCounters {
|
||||
impl<const N: usize> LogIngress<N> {
|
||||
const fn new() -> Self {
|
||||
assert!(N > 0, "log ingress must be nonempty");
|
||||
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),
|
||||
entries: UnsafeCell::new([None; N]),
|
||||
head: UnsafeCell::new(0),
|
||||
len: UnsafeCell::new(0),
|
||||
busy: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
fn publish(&self, level: Level, flags: LogFlags, arguments: fmt::Arguments<'_>) {
|
||||
let message = arguments
|
||||
.as_str()
|
||||
.map_or_else(|| LogText::from_arguments(arguments), LogText::from_message);
|
||||
let record = LogRecord::new(level, flags, message);
|
||||
let _ = self.with_state(|entries, head, len| {
|
||||
if *len < N {
|
||||
entries[(*head + *len) % N] = Some(record);
|
||||
*len += 1;
|
||||
return;
|
||||
}
|
||||
}) {
|
||||
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()
|
||||
)),
|
||||
if level != Level::Error {
|
||||
return;
|
||||
}
|
||||
} 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,
|
||||
let Some(oldest_best_effort) = (0..*len).find(|offset| {
|
||||
entries[(*head + *offset) % N]
|
||||
.is_some_and(|pending| pending.level() != Level::Error)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
Self::remove(entries, *head, len, oldest_best_effort);
|
||||
entries[(*head + *len) % N] = Some(record);
|
||||
*len += 1;
|
||||
});
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
fn remove(entries: &mut [Option<LogRecord>; N], head: usize, len: &mut usize, offset: usize) {
|
||||
debug_assert!(offset < *len);
|
||||
for current in offset..(*len - 1) {
|
||||
let source = (head + current + 1) % N;
|
||||
let destination = (head + current) % N;
|
||||
entries[destination] = entries[source].take();
|
||||
}
|
||||
entries[(head + *len - 1) % N] = None;
|
||||
*len -= 1;
|
||||
}
|
||||
|
||||
fn drain_one<R>(&self, consume: impl FnOnce(&LogRecord) -> R) -> Option<R> {
|
||||
self.with_state(|entries, head, len| {
|
||||
if *len == 0 {
|
||||
return None;
|
||||
}
|
||||
let record = entries[*head].as_ref()?;
|
||||
let result = consume(record);
|
||||
entries[*head] = None;
|
||||
*head = (*head + 1) % N;
|
||||
*len -= 1;
|
||||
Some(result)
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn with_state<R>(
|
||||
&self,
|
||||
operation: impl FnOnce(&mut [Option<LogRecord>; N], &mut usize, &mut usize) -> R,
|
||||
) -> Option<R> {
|
||||
if self
|
||||
.busy
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `busy` grants exclusive access to all three cells.
|
||||
let result = operation(
|
||||
unsafe { &mut *self.entries.get() },
|
||||
unsafe { &mut *self.head.get() },
|
||||
unsafe { &mut *self.len.get() },
|
||||
);
|
||||
self.busy.store(false, Ordering::Release);
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
/// Publishes one macro event to the fixed global ingress.
|
||||
#[doc(hidden)]
|
||||
#[inline]
|
||||
pub fn publish(level: Level, flags: LogFlags, arguments: fmt::Arguments<'_>) {
|
||||
GLOBAL_INGRESS.publish(level, flags, arguments);
|
||||
}
|
||||
|
||||
/// Visits and removes the oldest pending log event.
|
||||
///
|
||||
/// The callback runs during the ingress's one nonblocking ownership attempt;
|
||||
/// it should perform only bounded composition-level sink fanout.
|
||||
#[inline]
|
||||
pub fn drain_one<R>(consume: impl FnOnce(&LogRecord) -> R) -> Option<R> {
|
||||
GLOBAL_INGRESS.drain_one(consume)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use core::fmt;
|
||||
use std::sync::Once;
|
||||
use core::sync::atomic::Ordering;
|
||||
|
||||
use super::{FacadeIngress, GlobalLogger};
|
||||
use crate::{RecordChannel, Severity};
|
||||
use log::{Level, Log, Metadata, Record};
|
||||
use super::{LogIngress, drain_one};
|
||||
use crate::{Level, LogFlags};
|
||||
use std::sync::Mutex;
|
||||
|
||||
static GLOBAL_INGRESS: FacadeIngress<2> = FacadeIngress::new();
|
||||
static GLOBAL_LOGGER: GlobalLogger<2> = GlobalLogger::new(&GLOBAL_INGRESS, never_forbidden);
|
||||
static INSTALL_GLOBAL: Once = Once::new();
|
||||
static GLOBAL_INGRESS_TEST: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn never_forbidden() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn always_forbidden() -> bool {
|
||||
true
|
||||
#[test]
|
||||
fn every_macro_produces_the_same_log_record_kind() {
|
||||
let _guard = GLOBAL_INGRESS_TEST
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
while drain_one(|_| ()).is_some() {}
|
||||
crate::info!("configuration loaded");
|
||||
crate::error!("transfer failed");
|
||||
assert_eq!(drain_one(|record| record.level()), Some(Level::Info));
|
||||
assert_eq!(
|
||||
drain_one(|record| {
|
||||
assert_eq!(record.message().as_str(), "transfer failed");
|
||||
record.level()
|
||||
}),
|
||||
Some(Level::Error)
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
fn full_mixed_ingress_keeps_critical_records_and_evicts_best_effort() {
|
||||
let ingress = LogIngress::<2>::new();
|
||||
ingress.publish(Level::Info, LogFlags::NONE, core::format_args!("advance"));
|
||||
assert_eq!(
|
||||
ingress.drain_one(|record| assert_eq!(record.message().as_str(), "advance")),
|
||||
Some(())
|
||||
);
|
||||
ingress.publish(Level::Info, LogFlags::NONE, core::format_args!("optional"));
|
||||
ingress.publish(
|
||||
Level::Error,
|
||||
LogFlags::NONE,
|
||||
core::format_args!("first error"),
|
||||
);
|
||||
ingress.publish(
|
||||
Level::Error,
|
||||
LogFlags::NONE,
|
||||
core::format_args!("second error"),
|
||||
);
|
||||
|
||||
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!(
|
||||
ingress.drain_one(|record| assert_eq!(record.message().as_str(), "first error")),
|
||||
Some(())
|
||||
);
|
||||
assert_eq!(
|
||||
UNFILTERED
|
||||
.take()
|
||||
.expect("debug event reaches the unfiltered ingress")
|
||||
.severity(),
|
||||
Severity::Debug
|
||||
ingress.drain_one(|record| assert_eq!(record.message().as_str(), "second error")),
|
||||
Some(())
|
||||
);
|
||||
assert!(ingress.drain_one(|_| ()).is_none());
|
||||
}
|
||||
|
||||
#[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");
|
||||
fn full_critical_ingress_keeps_the_earlier_records() {
|
||||
let ingress = LogIngress::<2>::new();
|
||||
ingress.publish(
|
||||
Level::Error,
|
||||
LogFlags::NONE,
|
||||
core::format_args!("first error"),
|
||||
);
|
||||
ingress.publish(
|
||||
Level::Error,
|
||||
LogFlags::NONE,
|
||||
core::format_args!("second error"),
|
||||
);
|
||||
ingress.publish(
|
||||
Level::Error,
|
||||
LogFlags::NONE,
|
||||
core::format_args!("discarded error"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
pending.additional_information().unwrap().as_str(),
|
||||
"link failed"
|
||||
ingress.drain_one(|record| assert_eq!(record.message().as_str(), "first error")),
|
||||
Some(())
|
||||
);
|
||||
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")
|
||||
}
|
||||
assert_eq!(
|
||||
ingress.drain_one(|record| assert_eq!(record.message().as_str(), "second error")),
|
||||
Some(())
|
||||
);
|
||||
assert!(ingress.drain_one(|_| ()).is_none());
|
||||
}
|
||||
|
||||
#[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")
|
||||
);
|
||||
}
|
||||
fn contended_drain_keeps_the_record_for_a_later_attempt() {
|
||||
let ingress = LogIngress::<1>::new();
|
||||
ingress.publish(Level::Info, LogFlags::NONE, core::format_args!("pending"));
|
||||
|
||||
#[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);
|
||||
}
|
||||
ingress.busy.store(true, Ordering::Relaxed);
|
||||
let mut called = false;
|
||||
assert!(ingress.drain_one(|_| called = true).is_none());
|
||||
assert!(!called);
|
||||
ingress.busy.store(false, Ordering::Relaxed);
|
||||
|
||||
#[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(),
|
||||
assert_eq!(
|
||||
ingress.drain_one(|record| assert_eq!(record.message().as_str(), "pending")),
|
||||
Some(())
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
//! Bounded log filtering policy.
|
||||
|
||||
use crate::Severity;
|
||||
|
||||
/// Immutable policy for one log sink.
|
||||
///
|
||||
/// Filtering belongs to a sink rather than to event creation: MAVLink and a
|
||||
/// future blackbox may therefore select different policies without either
|
||||
/// sink suppressing the other's events.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum LogFilter {
|
||||
/// The sink receives no log events, including errors.
|
||||
Disabled,
|
||||
/// The sink receives this level and every more severe level.
|
||||
UpTo(Severity),
|
||||
}
|
||||
|
||||
impl LogFilter {
|
||||
/// Creates a disabled sink policy.
|
||||
pub const fn disabled() -> Self {
|
||||
Self::Disabled
|
||||
}
|
||||
|
||||
/// Creates a sink policy with one inclusive verbosity cap.
|
||||
pub const fn up_to(maximum_severity: Severity) -> Self {
|
||||
Self::UpTo(maximum_severity)
|
||||
}
|
||||
|
||||
/// The selected MAVLink-compatible severity cap, or `None` when disabled.
|
||||
pub const fn maximum_severity(self) -> Option<Severity> {
|
||||
match self {
|
||||
Self::Disabled => None,
|
||||
Self::UpTo(maximum_severity) => Some(maximum_severity),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this sink accepts the event.
|
||||
pub const fn allows(self, severity: Severity) -> bool {
|
||||
match self {
|
||||
Self::Disabled => false,
|
||||
Self::UpTo(maximum_severity) => severity.wire_value() <= maximum_severity.wire_value(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::LogFilter;
|
||||
use crate::Severity;
|
||||
|
||||
#[test]
|
||||
fn disabled_policy_rejects_every_severity() {
|
||||
let filter = LogFilter::disabled();
|
||||
for severity in [
|
||||
Severity::Emergency,
|
||||
Severity::Alert,
|
||||
Severity::Critical,
|
||||
Severity::Error,
|
||||
Severity::Warning,
|
||||
Severity::Notice,
|
||||
Severity::Info,
|
||||
Severity::Debug,
|
||||
] {
|
||||
assert!(!filter.allows(severity));
|
||||
}
|
||||
assert_eq!(filter.maximum_severity(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_policy_is_inclusive_and_rejects_more_verbose_events() {
|
||||
let filter = LogFilter::up_to(Severity::Info);
|
||||
assert!(filter.allows(Severity::Emergency));
|
||||
assert!(filter.allows(Severity::Error));
|
||||
assert!(filter.allows(Severity::Info));
|
||||
assert!(!filter.allows(Severity::Debug));
|
||||
assert_eq!(filter.maximum_severity(), Some(Severity::Info));
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
//! Bounded priority handling for fixed human-readable diagnostic records.
|
||||
//! Bounded priority handling for log records.
|
||||
|
||||
use core::fmt;
|
||||
|
||||
use crate::{
|
||||
DeliveryClass, ErrorAdditionalInformation, ErrorPath, ErrorReport, LogFilter, LogFlags,
|
||||
LogRecord, LogText, Severity, StaticError, StaticErrorMailbox,
|
||||
};
|
||||
use crate::{DeliveryClass, Level, LevelFilter, LogFlags, LogRecord, LogText};
|
||||
|
||||
/// Outcome from one bounded diagnostic admission attempt.
|
||||
/// Outcome from one bounded log admission attempt.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PublishOutcome {
|
||||
/// The record was retained.
|
||||
Queued,
|
||||
/// A record was excluded by this sink's configured policy.
|
||||
/// The configured level filter excluded the record.
|
||||
Filtered,
|
||||
/// A best-effort record was replaced by newer optional information.
|
||||
ReplacedOldest,
|
||||
@@ -20,13 +17,12 @@ pub enum PublishOutcome {
|
||||
RejectedFull,
|
||||
}
|
||||
|
||||
/// Result returned by the sole telemetry owner after attempting to admit a
|
||||
/// record to its lower transport.
|
||||
/// Result returned after attempting lower-transport admission.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TransportAdmission {
|
||||
/// The lower transport retained the record, so it may leave the queue.
|
||||
/// The lower transport retained the record.
|
||||
Accepted,
|
||||
/// The lower transport did not accept it yet; retain it for retry.
|
||||
/// The record must remain queued for retry.
|
||||
Retry,
|
||||
}
|
||||
|
||||
@@ -38,7 +34,7 @@ struct Lane<const N: usize> {
|
||||
|
||||
impl<const N: usize> Lane<N> {
|
||||
const fn new() -> Self {
|
||||
assert!(N > 0, "diagnostic lanes must be nonempty");
|
||||
assert!(N > 0, "log lanes must be nonempty");
|
||||
Self {
|
||||
entries: [None; N],
|
||||
head: 0,
|
||||
@@ -61,10 +57,6 @@ impl<const N: usize> Lane<N> {
|
||||
PublishOutcome::Queued
|
||||
}
|
||||
|
||||
const fn is_full(&self) -> bool {
|
||||
self.len == N
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<LogRecord> {
|
||||
self.entries.get(self.head).copied().flatten()
|
||||
}
|
||||
@@ -80,18 +72,16 @@ impl<const N: usize> Lane<N> {
|
||||
}
|
||||
}
|
||||
|
||||
/// One bounded priority queue owned by a log sink.
|
||||
///
|
||||
/// It owns no global logger, MAVLink state, DMA, filesystem, or other sink.
|
||||
/// One independently filtered bounded log-sink queue.
|
||||
pub struct LogSinkQueue<const CRITICAL: usize, const BEST_EFFORT: usize> {
|
||||
filter: LogFilter,
|
||||
filter: LevelFilter,
|
||||
critical: Lane<CRITICAL>,
|
||||
best_effort: Lane<BEST_EFFORT>,
|
||||
}
|
||||
|
||||
impl<const CRITICAL: usize, const BEST_EFFORT: usize> LogSinkQueue<CRITICAL, BEST_EFFORT> {
|
||||
/// Creates one sink queue with its immutable boot-active policy.
|
||||
pub const fn new(filter: LogFilter) -> Self {
|
||||
/// Creates a sink queue with one immutable boot-active filter.
|
||||
pub const fn new(filter: LevelFilter) -> Self {
|
||||
Self {
|
||||
filter,
|
||||
critical: Lane::new(),
|
||||
@@ -100,146 +90,50 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> LogSinkQueue<CRITICAL, BES
|
||||
}
|
||||
|
||||
/// Immutable sink policy.
|
||||
pub const fn filter(&self) -> LogFilter {
|
||||
pub const fn filter(&self) -> LevelFilter {
|
||||
self.filter
|
||||
}
|
||||
|
||||
/// Whether an error source may be consumed without losing an enabled
|
||||
/// critical record. Filtered errors may be consumed intentionally.
|
||||
pub const fn can_publish_error(&self) -> bool {
|
||||
!self.filter.allows(Severity::Error) || !self.critical.is_full()
|
||||
}
|
||||
|
||||
/// Consumes at most one source-local static-error mailbox in round-robin
|
||||
/// order. An enabled source remains untouched while the critical lane is
|
||||
/// full; a filtered source is consumed intentionally.
|
||||
pub fn drain_next_error_mailbox<const SOURCES: usize>(
|
||||
&mut self,
|
||||
mailboxes: [&StaticErrorMailbox; SOURCES],
|
||||
cursor: &mut u8,
|
||||
) -> bool {
|
||||
assert!(
|
||||
SOURCES != 0 && SOURCES <= u8::MAX as usize,
|
||||
"error mailbox fan-in must fit its u8 cursor"
|
||||
);
|
||||
if !self.can_publish_error() {
|
||||
return false;
|
||||
}
|
||||
if usize::from(*cursor) >= SOURCES {
|
||||
*cursor = 0;
|
||||
}
|
||||
|
||||
let mut checked = 0;
|
||||
while checked < SOURCES {
|
||||
let index = usize::from(*cursor);
|
||||
*cursor = if index + 1 == SOURCES { 0 } else { *cursor + 1 };
|
||||
checked += 1;
|
||||
|
||||
let (error, coalesced) = mailboxes[index].take();
|
||||
let Some(error) = error else {
|
||||
continue;
|
||||
};
|
||||
let flags = if coalesced {
|
||||
LogFlags::COALESCED
|
||||
} else {
|
||||
LogFlags::NONE
|
||||
};
|
||||
let outcome = self.publish_error_static(flags, error);
|
||||
debug_assert!(matches!(
|
||||
outcome,
|
||||
PublishOutcome::Queued | PublishOutcome::Filtered
|
||||
));
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Formats and retains one ordinary task/worker diagnostic. Error-level
|
||||
/// severities are emitted through `KAPTAN_ERROR`; lower severities use
|
||||
/// `KAPTAN_LOG`.
|
||||
/// Formats and retains one log event.
|
||||
pub fn publish_arguments(
|
||||
&mut self,
|
||||
severity: Severity,
|
||||
level: Level,
|
||||
flags: LogFlags,
|
||||
arguments: fmt::Arguments<'_>,
|
||||
) -> PublishOutcome {
|
||||
self.publish_text(severity, flags, LogText::from_arguments(arguments))
|
||||
self.publish_text(level, 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_record(LogRecord::from_error(flags, error))
|
||||
/// Retains one preformatted log event.
|
||||
pub fn publish_text(&mut self, level: Level, flags: LogFlags, text: LogText) -> PublishOutcome {
|
||||
self.publish_record(LogRecord::new(level, flags, text))
|
||||
}
|
||||
|
||||
/// Retains one static mailbox error. This is the ISR-safe counterpart to
|
||||
/// [`Self::publish_error`].
|
||||
pub fn publish_error_static(
|
||||
&mut self,
|
||||
flags: LogFlags,
|
||||
error: &'static StaticError,
|
||||
) -> PublishOutcome {
|
||||
self.publish_record(LogRecord::from_static_error(flags, error))
|
||||
}
|
||||
|
||||
/// Retains a preformatted error identity and optional detail. Both
|
||||
/// transport-neutral components already have the exact common diagnostic
|
||||
/// envelope capacities, so a sink adapter need not retain or clip them
|
||||
/// again.
|
||||
pub fn publish_error_text(
|
||||
&mut self,
|
||||
flags: LogFlags,
|
||||
path: ErrorPath,
|
||||
additional_information: ErrorAdditionalInformation,
|
||||
) -> PublishOutcome {
|
||||
self.publish_record(LogRecord::error(flags, path, additional_information))
|
||||
}
|
||||
|
||||
/// Retains one preformatted bounded text payload. Error-or-higher
|
||||
/// severities are independently encoded as `KAPTAN_ERROR`.
|
||||
pub fn publish_text(
|
||||
&mut self,
|
||||
severity: Severity,
|
||||
flags: LogFlags,
|
||||
text: LogText,
|
||||
) -> PublishOutcome {
|
||||
self.publish_record(LogRecord::new(severity, flags, text))
|
||||
}
|
||||
|
||||
/// Applies this sink's policy and retains one already-constructed event.
|
||||
///
|
||||
/// Composition may submit the same transport-neutral record to multiple
|
||||
/// sink queues without coupling event creation to any sink policy.
|
||||
/// Applies this sink's filter and retains one record.
|
||||
pub fn publish_record(&mut self, record: LogRecord) -> PublishOutcome {
|
||||
if !self.filter.allows(record.severity()) {
|
||||
if !self.filter.allows(record.level()) {
|
||||
return PublishOutcome::Filtered;
|
||||
}
|
||||
let delivery = record.delivery();
|
||||
match delivery {
|
||||
match record.delivery() {
|
||||
DeliveryClass::Critical => self.critical.publish(record, false),
|
||||
DeliveryClass::BestEffort => self.best_effort.publish(record, true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Highest-priority record without relinquishing it.
|
||||
/// Highest-priority pending record.
|
||||
pub fn peek(&self) -> Option<LogRecord> {
|
||||
self.critical.peek().or_else(|| self.best_effort.peek())
|
||||
}
|
||||
|
||||
/// Submits the current record through the sink owner and removes it only
|
||||
/// after `Accepted`. This keeps encoder/admission retry semantics local
|
||||
/// for every severity.
|
||||
/// Removes the current record only after successful lower admission.
|
||||
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::Retry => {}
|
||||
if admission == TransportAdmission::Accepted {
|
||||
let _ = self.pop();
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
@@ -252,219 +146,24 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> LogSinkQueue<CRITICAL, BES
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LogSinkQueue, PublishOutcome, TransportAdmission};
|
||||
use crate::{
|
||||
DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogRecord,
|
||||
LogText, RecordChannel, Severity, StaticError,
|
||||
};
|
||||
use crate::{DeliveryClass, Level, LevelFilter, LogFlags, LogText};
|
||||
|
||||
#[test]
|
||||
fn sink_policy_applies_to_errors_and_ordinary_logs() {
|
||||
let mut disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
|
||||
fn filter_and_priority_apply_only_to_logs() {
|
||||
let mut queue = LogSinkQueue::<1, 1>::new(LevelFilter::Info);
|
||||
assert_eq!(
|
||||
disabled.publish_text(
|
||||
Severity::Error,
|
||||
LogFlags::NONE,
|
||||
LogText::from_message("error")
|
||||
),
|
||||
PublishOutcome::Filtered
|
||||
);
|
||||
assert!(disabled.peek().is_none());
|
||||
|
||||
let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
|
||||
assert_eq!(
|
||||
queue.publish_text(
|
||||
Severity::Debug,
|
||||
LogFlags::NONE,
|
||||
LogText::from_message("debug")
|
||||
),
|
||||
queue.publish_text(Level::Debug, LogFlags::NONE, LogText::from_message("debug")),
|
||||
PublishOutcome::Filtered
|
||||
);
|
||||
assert_eq!(
|
||||
queue.publish_text(
|
||||
Severity::Error,
|
||||
LogFlags::NONE,
|
||||
LogText::from_message("error")
|
||||
),
|
||||
queue.publish_text(Level::Error, LogFlags::NONE, LogText::from_message("error")),
|
||||
PublishOutcome::Queued
|
||||
);
|
||||
let record = queue.peek().unwrap();
|
||||
assert_eq!(record.error_path().unwrap().as_str(), "error");
|
||||
assert!(record.additional_information().unwrap().is_empty());
|
||||
assert_eq!(record.channel(), RecordChannel::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_critical_lane_backpressures_only_an_enabled_error_source() {
|
||||
let mut enabled = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
|
||||
assert!(enabled.can_publish_error());
|
||||
assert_eq!(
|
||||
enabled.publish_text(
|
||||
Severity::Error,
|
||||
LogFlags::NONE,
|
||||
LogText::from_message("pending")
|
||||
),
|
||||
PublishOutcome::Queued
|
||||
);
|
||||
assert!(!enabled.can_publish_error());
|
||||
|
||||
let disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
|
||||
assert!(disabled.can_publish_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mailbox_fan_in_survives_backpressure_and_is_round_robin() {
|
||||
static FIRST: StaticError = StaticError::new("first");
|
||||
static SECOND: StaticError = StaticError::new("second");
|
||||
static THIRD: StaticError = StaticError::new("third");
|
||||
let first = crate::StaticErrorMailbox::new();
|
||||
let second = crate::StaticErrorMailbox::new();
|
||||
let third = crate::StaticErrorMailbox::new();
|
||||
let mailboxes = [&first, &second, &third];
|
||||
first.record(&FIRST);
|
||||
second.record(&SECOND);
|
||||
third.record(&THIRD);
|
||||
|
||||
let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
|
||||
let mut cursor = 0;
|
||||
assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor));
|
||||
assert_eq!(
|
||||
queue.peek().unwrap().error_path().unwrap().as_str(),
|
||||
"first"
|
||||
);
|
||||
|
||||
first.record(&FIRST);
|
||||
assert!(!queue.drain_next_error_mailbox(mailboxes, &mut cursor));
|
||||
assert_eq!(
|
||||
queue.peek().unwrap().error_path().unwrap().as_str(),
|
||||
"first"
|
||||
);
|
||||
|
||||
let mut expect = |path| {
|
||||
assert_eq!(queue.peek().unwrap().error_path().unwrap().as_str(), path);
|
||||
assert_eq!(
|
||||
queue.submit_next(|_| (TransportAdmission::Accepted, ())),
|
||||
Some(())
|
||||
);
|
||||
assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor));
|
||||
};
|
||||
expect("first");
|
||||
expect("second");
|
||||
expect("third");
|
||||
assert_eq!(
|
||||
queue.peek().unwrap().error_path().unwrap().as_str(),
|
||||
"first"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_events_remain_distinct_records() {
|
||||
static FAULT: StaticError = StaticError::new("driver::Fault");
|
||||
let mut queue = LogSinkQueue::<2, 1>::new(LogFilter::up_to(Severity::Debug));
|
||||
assert_eq!(
|
||||
queue.publish_error_static(LogFlags::NONE, &FAULT),
|
||||
PublishOutcome::Queued
|
||||
);
|
||||
assert_eq!(
|
||||
queue.publish_error_static(LogFlags::NONE, &FAULT),
|
||||
PublishOutcome::Queued
|
||||
);
|
||||
let record = queue.peek().unwrap();
|
||||
assert!(!record.flags().contains(LogFlags::COALESCED));
|
||||
assert_eq!(record.error_path().unwrap().as_str(), "driver::Fault");
|
||||
assert!(record.additional_information().unwrap().is_empty());
|
||||
assert_eq!(queue.peek().unwrap().delivery(), DeliveryClass::Critical);
|
||||
assert_eq!(
|
||||
queue.submit_next(|_| (TransportAdmission::Accepted, ())),
|
||||
Some(())
|
||||
);
|
||||
assert_eq!(queue.peek(), Some(record));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_error_detail_needs_no_producer_formatting() {
|
||||
static FAULT: StaticError =
|
||||
StaticError::with_additional_information("driver::Dma::Fault", "stream 7");
|
||||
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
|
||||
assert_eq!(
|
||||
hub.publish_error_static(LogFlags::NONE, &FAULT),
|
||||
PublishOutcome::Queued
|
||||
);
|
||||
let record = hub.peek().unwrap();
|
||||
assert_eq!(record.error_path().unwrap().as_str(), "driver::Dma::Fault");
|
||||
assert_eq!(
|
||||
record.additional_information().unwrap().as_str(),
|
||||
"stream 7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_identity_and_detail_are_independent_record_components() {
|
||||
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
|
||||
let _ = hub.publish_error_text(
|
||||
LogFlags::NONE,
|
||||
ErrorPath::from_message("driver::Icm42688::Transfer"),
|
||||
ErrorAdditionalInformation::from_message("SPI DMA timeout"),
|
||||
);
|
||||
let record = hub.peek().unwrap();
|
||||
assert_eq!(record.channel(), RecordChannel::Error);
|
||||
assert_eq!(
|
||||
record.error_path().unwrap().as_str(),
|
||||
"driver::Icm42688::Transfer"
|
||||
);
|
||||
assert_eq!(
|
||||
record.additional_information().unwrap().as_str(),
|
||||
"SPI DMA timeout"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_retains_every_severity() {
|
||||
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
|
||||
let _ = hub.publish_text(
|
||||
Severity::Error,
|
||||
LogFlags::NONE,
|
||||
LogText::from_message("fault"),
|
||||
);
|
||||
assert_eq!(
|
||||
hub.submit_next(|_| (TransportAdmission::Retry, ())),
|
||||
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::Retry, ())),
|
||||
Some(())
|
||||
);
|
||||
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::BestEffort);
|
||||
assert_eq!(
|
||||
hub.submit_next(|_| (TransportAdmission::Accepted, ())),
|
||||
Some(())
|
||||
);
|
||||
assert!(hub.peek().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_sink_policies_do_not_suppress_each_other() {
|
||||
let record = LogRecord::new(
|
||||
Severity::Info,
|
||||
LogFlags::NONE,
|
||||
LogText::from_message("flight ready"),
|
||||
);
|
||||
let mut mavlink = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
|
||||
let mut blackbox = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
|
||||
|
||||
assert_eq!(mavlink.publish_record(record), PublishOutcome::Filtered);
|
||||
assert_eq!(blackbox.publish_record(record), PublishOutcome::Queued);
|
||||
assert!(mavlink.peek().is_none());
|
||||
assert_eq!(blackbox.peek(), Some(record));
|
||||
assert!(queue.peek().is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,72 @@
|
||||
//! Standard log levels and their MAVLink severity projection.
|
||||
//! Compact conventional log levels and filtering.
|
||||
|
||||
/// MAVLink-compatible severity. Numeric values deliberately match
|
||||
/// `MAV_SEVERITY`.
|
||||
/// Severity of one emitted log event.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
|
||||
#[repr(u8)]
|
||||
pub enum Level {
|
||||
/// A runtime operation failed.
|
||||
Error = 1,
|
||||
/// Recoverable warning.
|
||||
Warn = 2,
|
||||
/// Informational operation.
|
||||
Info = 3,
|
||||
/// Diagnostic output.
|
||||
Debug = 4,
|
||||
/// Fine-grained diagnostic output.
|
||||
Trace = 5,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
/// Conventional uppercase label.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Error => "ERROR",
|
||||
Self::Warn => "WARN",
|
||||
Self::Info => "INFO",
|
||||
Self::Debug => "DEBUG",
|
||||
Self::Trace => "TRACE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conventional maximum log level, including the disabled state.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, kaptan_config_derive::ConfigEnum)]
|
||||
#[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,
|
||||
pub enum LevelFilter {
|
||||
/// Disable the sink.
|
||||
Off = 0,
|
||||
/// Accept error events only.
|
||||
Error = 1,
|
||||
/// Accept errors and warnings.
|
||||
Warn = 2,
|
||||
/// Accept through informational events.
|
||||
Info = 3,
|
||||
/// Accept through debug events.
|
||||
Debug = 4,
|
||||
/// Accept every event, including trace.
|
||||
Trace = 5,
|
||||
}
|
||||
|
||||
impl LevelFilter {
|
||||
/// Returns true when this filter accepts the event.
|
||||
pub const fn allows(self, level: Level) -> bool {
|
||||
level as u8 <= self as u8
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use core::mem::size_of;
|
||||
|
||||
use super::Severity;
|
||||
use super::{Level, LevelFilter};
|
||||
|
||||
#[test]
|
||||
fn severity_is_one_byte() {
|
||||
assert_eq!(size_of::<Severity>(), 1);
|
||||
fn levels_are_one_byte_and_filter_conventionally() {
|
||||
assert_eq!(size_of::<Level>(), 1);
|
||||
assert_eq!(size_of::<LevelFilter>(), 1);
|
||||
assert!(!LevelFilter::Off.allows(Level::Error));
|
||||
assert!(LevelFilter::Info.allows(Level::Error));
|
||||
assert!(LevelFilter::Info.allows(Level::Info));
|
||||
assert!(!LevelFilter::Info.allows(Level::Debug));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Bounded, transport-neutral runtime diagnostics for Kaptan.
|
||||
//! Bounded, transport-neutral runtime logging for Kaptan.
|
||||
//!
|
||||
//! This crate deliberately contains no MAVLink, board, filesystem, clock, or
|
||||
//! configuration dependency. Flight code reports bounded events to
|
||||
//! This crate deliberately contains no MAVLink, board, filesystem, or clock
|
||||
//! dependency. Its sole configuration coupling is compile-time enum metadata
|
||||
//! for [`LevelFilter`]. Flight code reports bounded events to
|
||||
//! independently filtered sink queues; a platform-owned adapter may later
|
||||
//! encode one queue for a link.
|
||||
//! Reporting is observational and must never influence flight control or
|
||||
@@ -13,35 +14,77 @@ extern crate self as kaptan_log;
|
||||
#[cfg(test)]
|
||||
extern crate std;
|
||||
|
||||
pub mod crash;
|
||||
#[cfg(feature = "facade")]
|
||||
pub mod facade;
|
||||
mod filter;
|
||||
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::{LogSinkQueue, PublishOutcome, TransportAdmission};
|
||||
pub use kaptan_log_derive::KaptanError;
|
||||
pub use level::Severity;
|
||||
pub use mailbox::StaticErrorMailbox;
|
||||
pub use report::{DeliveryClass, ErrorReport, LogFlags, LogRecord, RecordChannel, StaticError};
|
||||
pub use shutdown::{ShutdownReason, ShutdownReport};
|
||||
pub use text::{
|
||||
ErrorAdditionalInformation, ErrorPath, LogText, MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES,
|
||||
MAXIMUM_ERROR_PATH_BYTES, MAXIMUM_LOG_TEXT_BYTES,
|
||||
};
|
||||
pub use level::{Level, LevelFilter};
|
||||
pub use report::{DeliveryClass, LogFlags, LogRecord};
|
||||
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;
|
||||
pub use facade::drain_one;
|
||||
#[doc(hidden)]
|
||||
pub use facade::publish as __publish;
|
||||
|
||||
/// Emits a debug event through the bounded global ingress.
|
||||
#[macro_export]
|
||||
macro_rules! debug {
|
||||
($($argument:tt)+) => {
|
||||
$crate::__publish(
|
||||
$crate::Level::Debug,
|
||||
$crate::LogFlags::NONE,
|
||||
core::format_args!($($argument)+),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Emits an informational event through the bounded global ingress.
|
||||
#[macro_export]
|
||||
macro_rules! info {
|
||||
($($argument:tt)+) => {
|
||||
$crate::__publish(
|
||||
$crate::Level::Info,
|
||||
$crate::LogFlags::NONE,
|
||||
core::format_args!($($argument)+),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Emits a warning event through the bounded global ingress.
|
||||
#[macro_export]
|
||||
macro_rules! warn {
|
||||
($($argument:tt)+) => {
|
||||
$crate::__publish(
|
||||
$crate::Level::Warn,
|
||||
$crate::LogFlags::NONE,
|
||||
core::format_args!($($argument)+),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Emits an error event through the bounded global ingress.
|
||||
#[macro_export]
|
||||
macro_rules! error {
|
||||
($($argument:tt)+) => {
|
||||
$crate::__publish(
|
||||
$crate::Level::Error,
|
||||
$crate::LogFlags::NONE,
|
||||
core::format_args!($($argument)+),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Emits a trace event, represented as MAVLink Debug plus the trace flag.
|
||||
#[macro_export]
|
||||
macro_rules! trace {
|
||||
($($argument:tt)+) => {
|
||||
$crate::__publish(
|
||||
$crate::Level::Trace,
|
||||
$crate::LogFlags::NONE,
|
||||
core::format_args!($($argument)+),
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
//! Fixed atomic handoff for static derived error messages.
|
||||
|
||||
use core::{
|
||||
ptr,
|
||||
sync::atomic::{AtomicPtr, Ordering},
|
||||
};
|
||||
|
||||
use crate::StaticError;
|
||||
|
||||
/// One source-local static-fault mailbox with fixed bounded atomic attempts.
|
||||
///
|
||||
/// 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 {
|
||||
/// Pending pointer with its low alignment bit used as the coalesced flag.
|
||||
state: AtomicPtr<StaticError>,
|
||||
}
|
||||
|
||||
const COALESCED_TAG: usize = 1;
|
||||
const _: () = assert!(core::mem::align_of::<StaticError>() > COALESCED_TAG);
|
||||
|
||||
impl StaticErrorMailbox {
|
||||
/// Creates an empty mailbox.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
state: AtomicPtr::new(ptr::null_mut()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records one static message with at most two fixed atomic attempts: one
|
||||
/// to retain it, then one to tag an already pending observation.
|
||||
pub fn record(&self, error: &'static StaticError) {
|
||||
let error = core::ptr::from_ref(error).cast_mut();
|
||||
if let Err(pending) =
|
||||
self.state
|
||||
.compare_exchange(ptr::null_mut(), error, Ordering::AcqRel, Ordering::Acquire)
|
||||
{
|
||||
self.mark_coalesced(pending);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks only the exact pending pointer observed by `record`. If a worker
|
||||
/// drained it concurrently, the single CAS fails without leaving a flag
|
||||
/// that could attach to a later error.
|
||||
fn mark_coalesced(&self, pending: *mut StaticError) {
|
||||
if pending.addr() & COALESCED_TAG != 0 {
|
||||
return;
|
||||
}
|
||||
let tagged = pending.map_addr(|address| address | COALESCED_TAG);
|
||||
let _ = self
|
||||
.state
|
||||
.compare_exchange(pending, tagged, Ordering::AcqRel, Ordering::Acquire);
|
||||
}
|
||||
|
||||
/// Removes the retained first message and its coalesced marker.
|
||||
pub fn take(&self) -> (Option<&'static StaticError>, bool) {
|
||||
let state = self.state.swap(ptr::null_mut(), Ordering::AcqRel);
|
||||
if state.is_null() {
|
||||
return (None, false);
|
||||
}
|
||||
let coalesced = state.addr() & COALESCED_TAG != 0;
|
||||
let error = state.map_addr(|address| address & !COALESCED_TAG);
|
||||
// SAFETY: `record` stores only pointers to static messages and the
|
||||
// atomic swap transfers the exact pointer to this one worker. The low
|
||||
// tag bit is cleared before dereferencing the aligned pointer.
|
||||
let error = unsafe { error.as_ref() };
|
||||
(error, coalesced)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StaticErrorMailbox {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use core::sync::atomic::Ordering;
|
||||
|
||||
use super::StaticErrorMailbox;
|
||||
use crate::StaticError;
|
||||
|
||||
#[test]
|
||||
fn source_mailbox_keeps_the_first_static_message_and_marks_later_faults() {
|
||||
static FIRST: StaticError = StaticError::new("first");
|
||||
static SECOND: StaticError = StaticError::new("second");
|
||||
let mailbox = StaticErrorMailbox::new();
|
||||
mailbox.record(&FIRST);
|
||||
mailbox.record(&SECOND);
|
||||
assert_eq!(mailbox.take(), (Some(&FIRST), true));
|
||||
assert_eq!(mailbox.take(), (None, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_coalesced_mark_cannot_attach_to_the_next_error() {
|
||||
static FIRST: StaticError = StaticError::new("first");
|
||||
static NEXT: StaticError = StaticError::new("next");
|
||||
let mailbox = StaticErrorMailbox::new();
|
||||
mailbox.record(&FIRST);
|
||||
|
||||
// Reproduce the losing producer's observed pointer, then let the
|
||||
// worker drain it and a different error arrive before that producer's
|
||||
// bounded marking CAS.
|
||||
let observed = mailbox.state.load(Ordering::Acquire);
|
||||
assert_eq!(mailbox.take(), (Some(&FIRST), false));
|
||||
mailbox.record(&NEXT);
|
||||
mailbox.mark_coalesced(observed);
|
||||
|
||||
assert_eq!(mailbox.take(), (Some(&NEXT), false));
|
||||
assert_eq!(mailbox.take(), (None, false));
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,8 @@
|
||||
//! Bounded human-readable diagnostic records.
|
||||
//! Bounded human-readable log records.
|
||||
|
||||
use crate::{ErrorAdditionalInformation, ErrorPath, LogText, Severity};
|
||||
use crate::{Level, LogText};
|
||||
|
||||
/// Static identity and optional detail for one error.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct StaticError {
|
||||
path: &'static str,
|
||||
additional_information: &'static str,
|
||||
}
|
||||
|
||||
impl StaticError {
|
||||
/// Creates one compile-time error identity without additional detail.
|
||||
pub const fn new(path: &'static str) -> Self {
|
||||
Self {
|
||||
path,
|
||||
additional_information: "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates one compile-time error identity with static additional detail.
|
||||
pub const fn with_additional_information(
|
||||
path: &'static str,
|
||||
additional_information: &'static str,
|
||||
) -> Self {
|
||||
Self {
|
||||
path,
|
||||
additional_information,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable human-readable error identity.
|
||||
pub const fn path(self) -> &'static str {
|
||||
self.path
|
||||
}
|
||||
|
||||
/// Optional static human-facing detail.
|
||||
pub const fn additional_information(self) -> &'static str {
|
||||
self.additional_information
|
||||
}
|
||||
}
|
||||
|
||||
/// Implemented by [`crate::KaptanError`] derives.
|
||||
///
|
||||
/// This trait is deliberately pure: obtaining error identity never allocates,
|
||||
/// publishes telemetry, changes safety state, or serializes generic sources.
|
||||
pub trait ErrorReport {
|
||||
/// Returns static identity and optional detail for this error value.
|
||||
fn static_error(&self) -> &'static StaticError;
|
||||
}
|
||||
|
||||
/// Bounded MAVLink log flags.
|
||||
/// Flags belonging exclusively to a log record.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[repr(transparent)]
|
||||
pub struct LogFlags(u8);
|
||||
@@ -57,208 +10,70 @@ 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.
|
||||
/// Message text exceeded its fixed capacity.
|
||||
pub const TRUNCATED: Self = Self(1 << 1);
|
||||
/// Multiple ISR producer observations were folded into one mailbox event.
|
||||
pub const COALESCED: Self = Self(1 << 2);
|
||||
|
||||
/// Raw MAVLink bitmask.
|
||||
/// Raw transport bitmask.
|
||||
pub const fn bits(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Bits valid for an ordinary `KAPTAN_LOG` record.
|
||||
pub const fn log_bits(self) -> u8 {
|
||||
self.0 & (Self::TRACE.0 | Self::TRUNCATED.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,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum RecordBody {
|
||||
Log {
|
||||
message: LogText,
|
||||
},
|
||||
Error {
|
||||
path: ErrorPath,
|
||||
additional_information: ErrorAdditionalInformation,
|
||||
},
|
||||
}
|
||||
|
||||
/// Priority selected internally from a diagnostic level.
|
||||
/// Internal transport priority selected from a conventional log level.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DeliveryClass {
|
||||
/// Error logs retained independently from optional log verbosity.
|
||||
/// Error-level logs.
|
||||
Critical,
|
||||
/// Warning/info/debug/trace logs subject to the configured filter.
|
||||
/// Warning, info, debug, and trace logs.
|
||||
BestEffort,
|
||||
}
|
||||
|
||||
/// A fully formatted bounded diagnostic waiting for MAVLink admission.
|
||||
/// One bounded transport-neutral log event.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct LogRecord {
|
||||
severity: Severity,
|
||||
level: Level,
|
||||
flags: LogFlags,
|
||||
body: RecordBody,
|
||||
message: LogText,
|
||||
}
|
||||
|
||||
impl LogRecord {
|
||||
/// Creates one bounded diagnostic record. Error-or-higher severities use
|
||||
/// their supplied text as the error path with no additional information;
|
||||
/// ordinary severity records use `KAPTAN_LOG` message text.
|
||||
pub fn new(severity: Severity, flags: LogFlags, message: LogText) -> Self {
|
||||
if severity <= Severity::Error {
|
||||
let flags = with_truncation(flags, message);
|
||||
return Self::error_with_severity(
|
||||
severity,
|
||||
flags,
|
||||
ErrorPath::from_message(message.as_str()),
|
||||
ErrorAdditionalInformation::new(),
|
||||
);
|
||||
/// Creates one bounded log record at the supplied level.
|
||||
pub fn new(level: Level, mut flags: LogFlags, message: LogText) -> Self {
|
||||
if message.is_truncated() {
|
||||
flags = flags.with(LogFlags::TRUNCATED);
|
||||
}
|
||||
Self {
|
||||
severity,
|
||||
flags: with_truncation(flags, message),
|
||||
body: RecordBody::Log { message },
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an error-channel record with independently bounded identity
|
||||
/// and optional additional information.
|
||||
pub fn error(
|
||||
flags: LogFlags,
|
||||
path: ErrorPath,
|
||||
additional_information: ErrorAdditionalInformation,
|
||||
) -> Self {
|
||||
Self::error_with_severity(Severity::Error, flags, path, additional_information)
|
||||
}
|
||||
|
||||
/// Creates one record from a typed derived error without choosing a sink.
|
||||
pub fn from_error<E: ErrorReport>(flags: LogFlags, error: &E) -> Self {
|
||||
Self::from_static_error(flags, error.static_error())
|
||||
}
|
||||
|
||||
/// Creates one record from an ISR-safe static error without choosing a sink.
|
||||
pub fn from_static_error(flags: LogFlags, error: &'static StaticError) -> Self {
|
||||
Self::error(
|
||||
level,
|
||||
flags,
|
||||
ErrorPath::from_message(error.path()),
|
||||
ErrorAdditionalInformation::from_message(error.additional_information()),
|
||||
)
|
||||
}
|
||||
|
||||
fn error_with_severity(
|
||||
severity: Severity,
|
||||
flags: LogFlags,
|
||||
path: ErrorPath,
|
||||
additional_information: ErrorAdditionalInformation,
|
||||
) -> Self {
|
||||
debug_assert!(severity <= Severity::Error);
|
||||
Self {
|
||||
severity,
|
||||
flags: flags_for_error(flags, path, additional_information),
|
||||
body: RecordBody::Error {
|
||||
path,
|
||||
additional_information,
|
||||
},
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-compatible severity.
|
||||
pub const fn severity(&self) -> Severity {
|
||||
self.severity
|
||||
/// Conventional log level.
|
||||
pub const fn level(&self) -> Level {
|
||||
self.level
|
||||
}
|
||||
|
||||
/// Wire flag bitmask.
|
||||
/// Log-specific flag bitmask.
|
||||
pub const fn flags(&self) -> LogFlags {
|
||||
self.flags
|
||||
}
|
||||
|
||||
/// Fixed text carried by an ordinary `KAPTAN_LOG` record.
|
||||
pub const fn message(&self) -> Option<&LogText> {
|
||||
match &self.body {
|
||||
RecordBody::Log { message } => Some(message),
|
||||
RecordBody::Error { .. } => None,
|
||||
}
|
||||
/// Fixed human-readable message.
|
||||
pub const fn message(&self) -> &LogText {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// Fixed identity path carried by a `KAPTAN_ERROR` record.
|
||||
pub const fn error_path(&self) -> Option<&ErrorPath> {
|
||||
match &self.body {
|
||||
RecordBody::Log { .. } => None,
|
||||
RecordBody::Error { path, .. } => Some(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed optional information carried by a `KAPTAN_ERROR` record.
|
||||
pub const fn additional_information(&self) -> Option<&ErrorAdditionalInformation> {
|
||||
match &self.body {
|
||||
RecordBody::Log { .. } => None,
|
||||
RecordBody::Error {
|
||||
additional_information,
|
||||
..
|
||||
} => Some(additional_information),
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom MAVLink diagnostic family selected for this record.
|
||||
pub const fn channel(&self) -> RecordChannel {
|
||||
match self.body {
|
||||
RecordBody::Log { .. } => RecordChannel::Log,
|
||||
RecordBody::Error { .. } => RecordChannel::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal admission priority.
|
||||
/// Internal transport priority.
|
||||
pub const fn delivery(&self) -> DeliveryClass {
|
||||
match self.body {
|
||||
RecordBody::Log { .. } => DeliveryClass::BestEffort,
|
||||
RecordBody::Error { .. } => DeliveryClass::Critical,
|
||||
match self.level {
|
||||
Level::Error => DeliveryClass::Critical,
|
||||
Level::Warn | Level::Info | Level::Debug | Level::Trace => DeliveryClass::BestEffort,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_truncation(mut flags: LogFlags, value: LogText) -> LogFlags {
|
||||
if value.is_truncated() {
|
||||
flags = flags.with(LogFlags::TRUNCATED);
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
fn flags_for_error(
|
||||
mut flags: LogFlags,
|
||||
path: ErrorPath,
|
||||
additional_information: ErrorAdditionalInformation,
|
||||
) -> LogFlags {
|
||||
if path.is_truncated() || additional_information.is_truncated() {
|
||||
flags = flags.with(LogFlags::TRUNCATED);
|
||||
}
|
||||
flags
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
//! 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 {
|
||||
/// A user initiated the controlled stop.
|
||||
User = 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
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,40 @@
|
||||
//! Fixed-capacity UTF-8 diagnostic text.
|
||||
//! Fixed-capacity UTF-8 log text.
|
||||
|
||||
use core::fmt::{self, Write};
|
||||
|
||||
/// Maximum bytes retained in one ordinary log message.
|
||||
/// Maximum bytes retained in one log message.
|
||||
pub const MAXIMUM_LOG_TEXT_BYTES: usize = 160;
|
||||
/// Maximum bytes retained in one error identity path.
|
||||
pub const MAXIMUM_ERROR_PATH_BYTES: usize = 96;
|
||||
/// Maximum bytes retained in one error additional-information value.
|
||||
pub const MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES: usize = 64;
|
||||
|
||||
/// Fixed UTF-8 diagnostic text with an explicit truncation marker.
|
||||
/// Transport-neutral bounded log message.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct BoundedText<const N: usize> {
|
||||
bytes: [u8; N],
|
||||
pub struct LogText {
|
||||
bytes: [u8; MAXIMUM_LOG_TEXT_BYTES],
|
||||
length: u8,
|
||||
truncated: bool,
|
||||
// A byte keeps the all-zero value available to `Option<LogRecord>` via
|
||||
// `Level`'s enum niche. A `bool` has its own larger niche, for which Rust
|
||||
// selected the nonzero value `2` for `None`; that moved the complete
|
||||
// static ingress buffer from zero-initialized BSS into `.data`.
|
||||
truncated: u8,
|
||||
}
|
||||
|
||||
/// Transport-neutral ordinary log message.
|
||||
pub type LogText = BoundedText<MAXIMUM_LOG_TEXT_BYTES>;
|
||||
/// Transport-neutral error identity path.
|
||||
pub type ErrorPath = BoundedText<MAXIMUM_ERROR_PATH_BYTES>;
|
||||
/// Transport-neutral optional error detail.
|
||||
pub type ErrorAdditionalInformation = BoundedText<MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES>;
|
||||
|
||||
impl<const N: usize> BoundedText<N> {
|
||||
impl LogText {
|
||||
/// Creates empty fixed text storage.
|
||||
pub const fn new() -> Self {
|
||||
assert!(N > 0, "diagnostic text storage must be nonempty");
|
||||
assert!(N <= u8::MAX as usize, "diagnostic text length must fit u8");
|
||||
Self {
|
||||
bytes: [0; N],
|
||||
bytes: [0; MAXIMUM_LOG_TEXT_BYTES],
|
||||
length: 0,
|
||||
truncated: false,
|
||||
truncated: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats one message into fixed storage without allocation.
|
||||
/// Formats one message 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.
|
||||
/// Copies UTF-8 text without splitting a code point at the fixed boundary.
|
||||
pub fn from_message(message: &str) -> Self {
|
||||
let mut text = Self::new();
|
||||
let _ = text.write_str(message);
|
||||
@@ -52,116 +42,72 @@ impl<const N: usize> BoundedText<N> {
|
||||
}
|
||||
|
||||
/// Raw bounded wire bytes.
|
||||
pub const fn bytes(&self) -> &[u8; N] {
|
||||
pub const fn bytes(&self) -> &[u8; MAXIMUM_LOG_TEXT_BYTES] {
|
||||
&self.bytes
|
||||
}
|
||||
|
||||
/// Maximum number of UTF-8 bytes retained by this text type.
|
||||
pub const fn capacity(&self) -> usize {
|
||||
N
|
||||
}
|
||||
|
||||
/// Number of meaningful UTF-8 bytes.
|
||||
pub const fn len(&self) -> u8 {
|
||||
self.length
|
||||
}
|
||||
|
||||
/// Whether this record contains no message bytes.
|
||||
/// Whether this message contains no text.
|
||||
pub const fn is_empty(&self) -> bool {
|
||||
self.length == 0
|
||||
}
|
||||
|
||||
/// Whether the original message exceeded bounded storage.
|
||||
/// Whether the input exceeded fixed storage.
|
||||
pub const fn is_truncated(&self) -> bool {
|
||||
self.truncated
|
||||
self.truncated != 0
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
// `write_str` copies only complete code points from an input `&str`.
|
||||
// SAFETY: every populated byte belongs to that valid UTF-8 prefix.
|
||||
unsafe { core::str::from_utf8_unchecked(&self.bytes[..usize::from(self.length)]) }
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> Default for BoundedText<N> {
|
||||
impl Default for LogText {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> fmt::Write for BoundedText<N> {
|
||||
impl fmt::Write for LogText {
|
||||
fn write_str(&mut self, message: &str) -> fmt::Result {
|
||||
let remaining = MAXIMUM_LOG_TEXT_BYTES - usize::from(self.length);
|
||||
let prefix = if message.len() <= remaining {
|
||||
message.len()
|
||||
} else {
|
||||
self.truncated = 1;
|
||||
message
|
||||
.char_indices()
|
||||
.take_while(|(index, character)| index + character.len_utf8() <= remaining)
|
||||
.map(|(index, character)| index + character.len_utf8())
|
||||
.last()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let used = usize::from(self.length);
|
||||
let remaining = N - 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::{
|
||||
ErrorAdditionalInformation, ErrorPath, LogText, MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES,
|
||||
MAXIMUM_ERROR_PATH_BYTES, MAXIMUM_LOG_TEXT_BYTES,
|
||||
};
|
||||
use super::{LogText, MAXIMUM_LOG_TEXT_BYTES};
|
||||
|
||||
#[test]
|
||||
fn truncates_only_at_utf8_boundaries() {
|
||||
let short = LogText::from_message("ready");
|
||||
assert!(!short.is_truncated());
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_semantic_component_owns_only_its_exact_capacity() {
|
||||
assert_eq!(LogText::new().capacity(), MAXIMUM_LOG_TEXT_BYTES);
|
||||
assert_eq!(ErrorPath::new().capacity(), MAXIMUM_ERROR_PATH_BYTES);
|
||||
assert_eq!(
|
||||
ErrorAdditionalInformation::new().capacity(),
|
||||
MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES
|
||||
);
|
||||
|
||||
assert_eq!(core::mem::size_of::<LogText>(), MAXIMUM_LOG_TEXT_BYTES + 2);
|
||||
assert_eq!(
|
||||
core::mem::size_of::<ErrorPath>(),
|
||||
MAXIMUM_ERROR_PATH_BYTES + 2
|
||||
);
|
||||
assert_eq!(
|
||||
core::mem::size_of::<ErrorAdditionalInformation>(),
|
||||
MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES + 2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_components_truncate_at_their_own_utf8_boundaries() {
|
||||
let path = ErrorPath::from_message(&("p".repeat(MAXIMUM_ERROR_PATH_BYTES - 1) + "é"));
|
||||
assert!(path.is_truncated());
|
||||
assert_eq!(path.as_str().len(), MAXIMUM_ERROR_PATH_BYTES - 1);
|
||||
|
||||
let information = ErrorAdditionalInformation::from_message(
|
||||
&("i".repeat(MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES - 1) + "é"),
|
||||
);
|
||||
assert!(information.is_truncated());
|
||||
assert_eq!(
|
||||
information.as_str().len(),
|
||||
MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES - 1
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user