From ba9c97ed3b3cdc1acacb973415b8478521b3b492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onur=20=C3=96zkan?= Date: Sun, 23 Aug 2026 00:40:17 +0300 Subject: [PATCH] feat(errors): add stable error propagation --- .../kaptan-err-propagate-macros/Cargo.toml | 20 ++ .../kaptan-err-propagate-macros/src/lib.rs | 242 ++++++++++++++++++ .../kaptan-err-propagate-macros/tests/ui.rs | 17 ++ .../tests/ui/arguments.rs | 8 + .../tests/ui/arguments.stderr | 7 + .../tests/ui/const_fn.rs | 8 + .../tests/ui/const_fn.stderr | 5 + .../tests/ui/duplicate_id.rs | 13 + .../tests/ui/duplicate_id.stderr | 5 + .../tests/ui/duplicate_id_across_enums.rs | 37 +++ .../tests/ui/duplicate_id_across_enums.stderr | 7 + .../tests/ui/enum.rs | 43 ++++ .../tests/ui/missing_id.rs | 10 + .../tests/ui/missing_id.stderr | 5 + .../tests/ui/missing_return.rs | 6 + .../tests/ui/missing_return.stderr | 7 + .../tests/ui/no_error_report.rs | 18 ++ .../tests/ui/no_error_report.stderr | 17 ++ .../tests/ui/non_result.rs | 14 + .../tests/ui/non_result.stderr | 17 ++ .../tests/ui/struct.rs | 8 + .../tests/ui/struct.stderr | 5 + .../tests/ui/union.rs | 8 + .../tests/ui/union.stderr | 5 + .../tests/ui/unsafe_fn.rs | 8 + .../tests/ui/unsafe_fn.stderr | 5 + .../tests/ui/zero_id.rs | 11 + .../tests/ui/zero_id.stderr | 5 + firmware/libs/kaptan-err-propagate/Cargo.toml | 12 + .../libs/kaptan-err-propagate/src/ingress.rs | 97 +++++++ firmware/libs/kaptan-err-propagate/src/lib.rs | 41 +++ .../libs/kaptan-err-propagate/src/record.rs | 68 +++++ .../libs/kaptan-err-propagate/src/sink.rs | 79 ++++++ .../kaptan-err-propagate/tests/propagate.rs | 149 +++++++++++ 34 files changed, 1007 insertions(+) create mode 100644 firmware/libs/kaptan-err-propagate-macros/Cargo.toml create mode 100644 firmware/libs/kaptan-err-propagate-macros/src/lib.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/arguments.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/arguments.stderr create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/const_fn.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/const_fn.stderr create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id.stderr create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id_across_enums.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id_across_enums.stderr create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/enum.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_id.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_id.stderr create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_return.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_return.stderr create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/no_error_report.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/no_error_report.stderr create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/non_result.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/non_result.stderr create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/struct.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/struct.stderr create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/union.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/union.stderr create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/unsafe_fn.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/unsafe_fn.stderr create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/zero_id.rs create mode 100644 firmware/libs/kaptan-err-propagate-macros/tests/ui/zero_id.stderr create mode 100644 firmware/libs/kaptan-err-propagate/Cargo.toml create mode 100644 firmware/libs/kaptan-err-propagate/src/ingress.rs create mode 100644 firmware/libs/kaptan-err-propagate/src/lib.rs create mode 100644 firmware/libs/kaptan-err-propagate/src/record.rs create mode 100644 firmware/libs/kaptan-err-propagate/src/sink.rs create mode 100644 firmware/libs/kaptan-err-propagate/tests/propagate.rs diff --git a/firmware/libs/kaptan-err-propagate-macros/Cargo.toml b/firmware/libs/kaptan-err-propagate-macros/Cargo.toml new file mode 100644 index 0000000..ca72730 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "kaptan-err-propagate-macros" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +[lib] +proc-macro = true + +[lints] +workspace = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } + +[dev-dependencies] +trybuild = "1" diff --git a/firmware/libs/kaptan-err-propagate-macros/src/lib.rs b/firmware/libs/kaptan-err-propagate-macros/src/lib.rs new file mode 100644 index 0000000..c4e2f4a --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/src/lib.rs @@ -0,0 +1,242 @@ +//! Attribute expansion for `kaptan-err-propagate`. + +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use std::collections::BTreeMap; +use syn::{ + Data, DeriveInput, Error, Fields, ItemFn, LitInt, ReturnType, Variant, parse_macro_input, + parse_quote, spanned::Spanned, +}; + +/// Implements `kaptan_err_propagate::ErrorReport`, [`core::fmt::Display`], and +/// [`core::error::Error`] for an explicitly numbered error enum. +/// +/// Every variant must declare a stable nonzero +/// `#[kaptan_error(id = ...)]`. Duplicate IDs fail compilation within an enum +/// or crate and fail final linking when they originate in different crates. +#[proc_macro_derive(KaptanError, attributes(kaptan_error))] +pub fn derive_kaptan_error(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_error(input) + .unwrap_or_else(Error::into_compile_error) + .into() +} + +/// Reports an [`Err`] only when it leaves the annotated function. +/// +/// The function must have an explicit return type compatible with +/// [`core::result::Result`]; aliases are accepted. Const, unsafe, variadic, +/// and foreign-ABI functions are rejected because an immediately invoked +/// closure or async block cannot preserve those execution contracts without +/// additional unsafe or non-const machinery. +#[proc_macro_attribute] +pub fn propagate(arguments: TokenStream, input: TokenStream) -> TokenStream { + if !arguments.is_empty() { + return Error::new( + proc_macro2::Span::call_site(), + "kaptan_err_propagate::propagate does not accept arguments", + ) + .into_compile_error() + .into(); + } + + let function = parse_macro_input!(input as ItemFn); + expand(function) + .unwrap_or_else(Error::into_compile_error) + .into() +} + +fn expand(mut function: ItemFn) -> syn::Result { + validate_signature(&function)?; + + let body = function.block; + function.block = if function.sig.asyncness.is_some() { + Box::new(syn::parse_quote!({ + const __KAPTAN_RAISED_AT: &str = concat!(file!(), ":", line!()); + let __kaptan_err_propagate_result = (async #body).await; + ::kaptan_err_propagate::__private::report_result( + &__kaptan_err_propagate_result, + __KAPTAN_RAISED_AT, + ); + __kaptan_err_propagate_result + })) + } else { + Box::new(syn::parse_quote!({ + const __KAPTAN_RAISED_AT: &str = concat!(file!(), ":", line!()); + let __kaptan_err_propagate_result = (|| #body)(); + ::kaptan_err_propagate::__private::report_result( + &__kaptan_err_propagate_result, + __KAPTAN_RAISED_AT, + ); + __kaptan_err_propagate_result + })) + }; + + Ok(quote!(#function)) +} + +fn validate_signature(function: &ItemFn) -> syn::Result<()> { + let signature = &function.sig; + if let Some(token) = signature.constness { + return Err(Error::new( + token.span(), + "kaptan_err_propagate::propagate does not support const functions", + )); + } + if let Some(token) = signature.unsafety { + return Err(Error::new( + token.span(), + "kaptan_err_propagate::propagate does not support unsafe functions", + )); + } + if let Some(abi) = &signature.abi { + return Err(Error::new( + abi.span(), + "kaptan_err_propagate::propagate does not support foreign-ABI functions", + )); + } + if let Some(variadic) = &signature.variadic { + return Err(Error::new( + variadic.span(), + "kaptan_err_propagate::propagate does not support variadic functions", + )); + } + + if matches!(signature.output, ReturnType::Default) { + return Err(Error::new( + signature.output.span(), + "kaptan_err_propagate::propagate requires an explicit Result return type", + )); + } + Ok(()) +} + +fn expand_error(input: DeriveInput) -> syn::Result { + let DeriveInput { + ident, + generics, + data, + .. + } = input; + let Data::Enum(data) = data else { + let kind = match data { + Data::Struct(_) => "structs", + Data::Union(_) => "unions", + Data::Enum(_) => unreachable!("enum handled above"), + }; + return Err(Error::new_spanned( + ident, + format!("KaptanError can only be derived for enums, not {kind}"), + )); + }; + + let mut ids = BTreeMap::new(); + let variants = data + .variants + .iter() + .map(|variant| { + let id = variant_id(variant)?; + if let Some(previous) = ids.insert(id, &variant.ident) { + return Err(Error::new_spanned( + &variant.ident, + format!("Kaptan error ID {id} is already assigned to variant {previous}",), + )); + } + Ok((variant, id)) + }) + .collect::>>()?; + let identities = variants.iter().map(|(variant, id)| { + let variant_ident = &variant.ident; + let static_ident = format_ident!( + "__KAPTAN_ERROR_ID_{}_{}", + ident.to_string().to_uppercase(), + variant_ident.to_string().to_uppercase(), + ); + let symbol = syn::LitStr::new(&format!("__kaptan_error_id_{id}"), variant_ident.span()); + quote! { + #[doc(hidden)] + #[used] + #[unsafe(export_name = #symbol)] + static #static_ident: u8 = 0; + } + }); + let arms = variants.iter().map(|(variant, id)| { + let variant_ident = &variant.ident; + let pattern = match &variant.fields { + Fields::Unit => quote!(Self::#variant_ident), + Fields::Unnamed(_) => quote!(Self::#variant_ident(..)), + Fields::Named(_) => quote!(Self::#variant_ident { .. }), + }; + quote! { + #pattern => ::kaptan_err_propagate::ErrorId::new(#id) + } + }); + let (impl_generics, type_generics, where_clause) = generics.split_for_impl(); + let mut error_generics = generics.clone(); + error_generics + .make_where_clause() + .predicates + .push(parse_quote!(#ident #type_generics: ::core::fmt::Debug)); + let (error_impl_generics, error_type_generics, error_where_clause) = + error_generics.split_for_impl(); + + Ok(quote! { + #(#identities)* + + impl #impl_generics ::kaptan_err_propagate::ErrorReport for #ident #type_generics #where_clause { + fn error_id(&self) -> ::kaptan_err_propagate::ErrorId { + match self { + #(#arms),* + } + } + } + + impl #impl_generics ::core::fmt::Display for #ident #type_generics #where_clause { + fn fmt( + &self, + formatter: &mut ::core::fmt::Formatter<'_>, + ) -> ::core::fmt::Result { + ::core::fmt::Display::fmt( + &::error_id(self).get(), + formatter, + ) + } + } + + impl #error_impl_generics ::core::error::Error + for #ident #error_type_generics #error_where_clause {} + }) +} + +fn variant_id(variant: &Variant) -> syn::Result { + let mut id = None; + for attribute in &variant.attrs { + if !attribute.path().is_ident("kaptan_error") { + continue; + } + attribute.parse_nested_meta(|meta| { + if !meta.path.is_ident("id") { + return Err(meta.error("unsupported Kaptan error property; expected `id`")); + } + if id.is_some() { + return Err(meta.error("duplicate Kaptan error `id`")); + } + let literal: LitInt = meta.value()?.parse()?; + let value = literal.base10_parse::()?; + if value == 0 { + return Err(Error::new( + literal.span(), + "Kaptan error ID zero is reserved", + )); + } + id = Some(value); + Ok(()) + })?; + } + id.ok_or_else(|| { + Error::new_spanned( + &variant.ident, + "missing `#[kaptan_error(id = ...)]` on Kaptan error variant", + ) + }) +} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui.rs new file mode 100644 index 0000000..2da3276 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui.rs @@ -0,0 +1,17 @@ +#[test] +fn propagate_rejects_unsupported_signatures() { + let tests = trybuild::TestCases::new(); + tests.pass("tests/ui/enum.rs"); + tests.compile_fail("tests/ui/arguments.rs"); + tests.compile_fail("tests/ui/const_fn.rs"); + tests.compile_fail("tests/ui/duplicate_id.rs"); + tests.compile_fail("tests/ui/duplicate_id_across_enums.rs"); + tests.compile_fail("tests/ui/missing_id.rs"); + tests.compile_fail("tests/ui/missing_return.rs"); + tests.compile_fail("tests/ui/non_result.rs"); + tests.compile_fail("tests/ui/no_error_report.rs"); + tests.compile_fail("tests/ui/struct.rs"); + tests.compile_fail("tests/ui/union.rs"); + tests.compile_fail("tests/ui/unsafe_fn.rs"); + tests.compile_fail("tests/ui/zero_id.rs"); +} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/arguments.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui/arguments.rs new file mode 100644 index 0000000..bb837f2 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/arguments.rs @@ -0,0 +1,8 @@ +use kaptan_err_propagate_macros::propagate; + +#[propagate(report)] +fn invalid() -> Result<(), ()> { + Ok(()) +} + +fn main() {} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/arguments.stderr b/firmware/libs/kaptan-err-propagate-macros/tests/ui/arguments.stderr new file mode 100644 index 0000000..9d2e06d --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/arguments.stderr @@ -0,0 +1,7 @@ +error: kaptan_err_propagate::propagate does not accept arguments + --> tests/ui/arguments.rs:3:1 + | +3 | #[propagate(report)] + | ^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `propagate` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/const_fn.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui/const_fn.rs new file mode 100644 index 0000000..6e6a41f --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/const_fn.rs @@ -0,0 +1,8 @@ +use kaptan_err_propagate_macros::propagate; + +#[propagate] +const fn invalid() -> Result<(), ()> { + Ok(()) +} + +fn main() {} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/const_fn.stderr b/firmware/libs/kaptan-err-propagate-macros/tests/ui/const_fn.stderr new file mode 100644 index 0000000..fb9c246 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/const_fn.stderr @@ -0,0 +1,5 @@ +error: kaptan_err_propagate::propagate does not support const functions + --> tests/ui/const_fn.rs:4:1 + | +4 | const fn invalid() -> Result<(), ()> { + | ^^^^^ diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id.rs new file mode 100644 index 0000000..1578e47 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id.rs @@ -0,0 +1,13 @@ +extern crate self as kaptan_err_propagate; + +use kaptan_err_propagate_macros::KaptanError; + +#[derive(Debug, KaptanError)] +enum DuplicateId { + #[kaptan_error(id = 42)] + First, + #[kaptan_error(id = 42)] + Second, +} + +fn main() {} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id.stderr b/firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id.stderr new file mode 100644 index 0000000..840a9c8 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id.stderr @@ -0,0 +1,5 @@ +error: Kaptan error ID 42 is already assigned to variant First + --> tests/ui/duplicate_id.rs:10:5 + | +10 | Second, + | ^^^^^^ diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id_across_enums.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id_across_enums.rs new file mode 100644 index 0000000..597f0c1 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id_across_enums.rs @@ -0,0 +1,37 @@ +extern crate self as kaptan_err_propagate; + +use kaptan_err_propagate_macros::KaptanError; + +#[derive(Clone, Copy)] +pub struct ErrorId(u16); + +impl ErrorId { + pub const fn new(id: u16) -> Self { + Self(id) + } + + pub const fn get(self) -> u16 { + self.0 + } +} + +pub trait ErrorReport { + fn error_id(&self) -> ErrorId; +} + +#[derive(Debug, KaptanError)] +enum FirstError { + #[kaptan_error(id = 77)] + Failed, +} + +#[derive(Debug, KaptanError)] +enum SecondError { + #[kaptan_error(id = 77)] + Failed, +} + +fn main() { + let _ = FirstError::Failed.error_id(); + let _ = SecondError::Failed.error_id(); +} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id_across_enums.stderr b/firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id_across_enums.stderr new file mode 100644 index 0000000..d1bbae9 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/duplicate_id_across_enums.stderr @@ -0,0 +1,7 @@ +error: symbol `__kaptan_error_id_77` is already defined + --> tests/ui/duplicate_id_across_enums.rs:28:17 + | +28 | #[derive(Debug, KaptanError)] + | ^^^^^^^^^^^ + | + = note: this error originates in the derive macro `KaptanError` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/enum.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui/enum.rs new file mode 100644 index 0000000..c4a9218 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/enum.rs @@ -0,0 +1,43 @@ +extern crate self as kaptan_err_propagate; + +use kaptan_err_propagate_macros::KaptanError; + +#[derive(Clone, Copy)] +pub struct ErrorId(u16); + +impl ErrorId { + pub const fn new(id: u16) -> Self { + Self(id) + } + + pub const fn get(self) -> u16 { + self.0 + } +} + +pub trait ErrorReport { + fn error_id(&self) -> ErrorId; +} + +#[derive(Debug, KaptanError)] +enum Generic<'a, T> +where + T: Copy, +{ + #[kaptan_error(id = 65201)] + Unit, + #[kaptan_error(id = 65202)] + Tuple(T, u8), + #[kaptan_error(id = 65203)] + Named { label: &'a str }, +} + +fn main() { + fn assert_error() {} + assert_error::>(); + assert_eq!(Generic::::Unit.error_id().get(), 65201); + assert_eq!(Generic::Tuple(1_u8, 2).error_id().get(), 65202); + let named: Generic<'_, u8> = Generic::Named { label: "test" }; + assert_eq!(named.error_id().get(), 65203); + assert_eq!(named.to_string(), "65203"); +} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_id.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_id.rs new file mode 100644 index 0000000..1aae879 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_id.rs @@ -0,0 +1,10 @@ +extern crate self as kaptan_err_propagate; + +use kaptan_err_propagate_macros::KaptanError; + +#[derive(Debug, KaptanError)] +enum MissingId { + Variant, +} + +fn main() {} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_id.stderr b/firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_id.stderr new file mode 100644 index 0000000..291331d --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_id.stderr @@ -0,0 +1,5 @@ +error: missing `#[kaptan_error(id = ...)]` on Kaptan error variant + --> tests/ui/missing_id.rs:7:5 + | +7 | Variant, + | ^^^^^^^ diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_return.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_return.rs new file mode 100644 index 0000000..411a256 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_return.rs @@ -0,0 +1,6 @@ +use kaptan_err_propagate_macros::propagate; + +#[propagate] +fn invalid() {} + +fn main() {} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_return.stderr b/firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_return.stderr new file mode 100644 index 0000000..40f29e9 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/missing_return.stderr @@ -0,0 +1,7 @@ +error: kaptan_err_propagate::propagate requires an explicit Result return type + --> tests/ui/missing_return.rs:3:1 + | +3 | #[propagate] + | ^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `propagate` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/no_error_report.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui/no_error_report.rs new file mode 100644 index 0000000..399a557 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/no_error_report.rs @@ -0,0 +1,18 @@ +use kaptan_err_propagate_macros::propagate; + +extern crate self as kaptan_err_propagate; + +pub trait ErrorReport {} + +pub mod __private { + pub fn report_result(_: &Result, _: &'static str) {} +} + +struct PlainError; + +#[propagate] +fn invalid() -> Result<(), PlainError> { + Err(PlainError) +} + +fn main() {} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/no_error_report.stderr b/firmware/libs/kaptan-err-propagate-macros/tests/ui/no_error_report.stderr new file mode 100644 index 0000000..2d3c053 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/no_error_report.stderr @@ -0,0 +1,17 @@ +error[E0277]: the trait bound `PlainError: ErrorReport` is not satisfied + --> tests/ui/no_error_report.rs:13:1 + | +13 | #[propagate] + | ^^^^^^^^^^^^ the trait `ErrorReport` is not implemented for `PlainError` + | +help: this trait has no implementations, consider adding one + --> tests/ui/no_error_report.rs:5:1 + | + 5 | pub trait ErrorReport {} + | ^^^^^^^^^^^^^^^^^^^^^ +note: required by a bound in `report_result` + --> tests/ui/no_error_report.rs:8:32 + | + 8 | pub fn report_result(_: &Result, _: &'static str) {} + | ^^^^^^^^^^^^^^^^^^ required by this bound in `report_result` + = note: this error originates in the attribute macro `propagate` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/non_result.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui/non_result.rs new file mode 100644 index 0000000..2a0db36 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/non_result.rs @@ -0,0 +1,14 @@ +use kaptan_err_propagate_macros::propagate; + +extern crate self as kaptan_err_propagate; + +pub mod __private { + pub fn report_result(_: &Result, _: &'static str) {} +} + +#[propagate] +fn invalid() -> bool { + true +} + +fn main() {} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/non_result.stderr b/firmware/libs/kaptan-err-propagate-macros/tests/ui/non_result.stderr new file mode 100644 index 0000000..cbf24af --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/non_result.stderr @@ -0,0 +1,17 @@ +error[E0308]: mismatched types + --> tests/ui/non_result.rs:9:1 + | +9 | #[propagate] + | ^^^^^^^^^^^^ + | | + | expected `&Result<_, _>`, found `&bool` + | arguments to this function are incorrect + | + = note: expected reference `&Result<_, _>` + found reference `&bool` +note: function defined here + --> tests/ui/non_result.rs:6:12 + | +6 | pub fn report_result(_: &Result, _: &'static str) {} + | ^^^^^^^^^^^^^ ---------------- + = note: this error originates in the attribute macro `propagate` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/struct.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui/struct.rs new file mode 100644 index 0000000..79c7587 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/struct.rs @@ -0,0 +1,8 @@ +use kaptan_err_propagate_macros::KaptanError; + +#[derive(KaptanError)] +struct NotAnError { + code: u8, +} + +fn main() {} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/struct.stderr b/firmware/libs/kaptan-err-propagate-macros/tests/ui/struct.stderr new file mode 100644 index 0000000..bc44d33 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/struct.stderr @@ -0,0 +1,5 @@ +error: KaptanError can only be derived for enums, not structs + --> tests/ui/struct.rs:4:8 + | +4 | struct NotAnError { + | ^^^^^^^^^^ diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/union.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui/union.rs new file mode 100644 index 0000000..fb5a7c9 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/union.rs @@ -0,0 +1,8 @@ +use kaptan_err_propagate_macros::KaptanError; + +#[derive(KaptanError)] +union NotAnError { + code: u8, +} + +fn main() {} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/union.stderr b/firmware/libs/kaptan-err-propagate-macros/tests/ui/union.stderr new file mode 100644 index 0000000..9daf8d2 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/union.stderr @@ -0,0 +1,5 @@ +error: KaptanError can only be derived for enums, not unions + --> tests/ui/union.rs:4:7 + | +4 | union NotAnError { + | ^^^^^^^^^^ diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/unsafe_fn.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui/unsafe_fn.rs new file mode 100644 index 0000000..0c75a53 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/unsafe_fn.rs @@ -0,0 +1,8 @@ +use kaptan_err_propagate_macros::propagate; + +#[propagate] +unsafe fn invalid() -> Result<(), ()> { + Ok(()) +} + +fn main() {} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/unsafe_fn.stderr b/firmware/libs/kaptan-err-propagate-macros/tests/ui/unsafe_fn.stderr new file mode 100644 index 0000000..96e74cf --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/unsafe_fn.stderr @@ -0,0 +1,5 @@ +error: kaptan_err_propagate::propagate does not support unsafe functions + --> tests/ui/unsafe_fn.rs:4:1 + | +4 | unsafe fn invalid() -> Result<(), ()> { + | ^^^^^^ diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/zero_id.rs b/firmware/libs/kaptan-err-propagate-macros/tests/ui/zero_id.rs new file mode 100644 index 0000000..98ad048 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/zero_id.rs @@ -0,0 +1,11 @@ +extern crate self as kaptan_err_propagate; + +use kaptan_err_propagate_macros::KaptanError; + +#[derive(Debug, KaptanError)] +enum ZeroId { + #[kaptan_error(id = 0)] + Variant, +} + +fn main() {} diff --git a/firmware/libs/kaptan-err-propagate-macros/tests/ui/zero_id.stderr b/firmware/libs/kaptan-err-propagate-macros/tests/ui/zero_id.stderr new file mode 100644 index 0000000..73cf311 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate-macros/tests/ui/zero_id.stderr @@ -0,0 +1,5 @@ +error: Kaptan error ID zero is reserved + --> tests/ui/zero_id.rs:7:25 + | +7 | #[kaptan_error(id = 0)] + | ^ diff --git a/firmware/libs/kaptan-err-propagate/Cargo.toml b/firmware/libs/kaptan-err-propagate/Cargo.toml new file mode 100644 index 0000000..517f9a0 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "kaptan-err-propagate" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +kaptan-err-propagate-macros = { path = "../kaptan-err-propagate-macros" } diff --git a/firmware/libs/kaptan-err-propagate/src/ingress.rs b/firmware/libs/kaptan-err-propagate/src/ingress.rs new file mode 100644 index 0000000..d1ea8f5 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate/src/ingress.rs @@ -0,0 +1,97 @@ +//! Fixed, allocation-free typed-error ingress. + +use core::{ + cell::UnsafeCell, + mem::MaybeUninit, + sync::atomic::{AtomicU8, Ordering}, +}; + +use crate::{ErrorId, ErrorRecord}; + +static GLOBAL_INGRESS: ErrorIngress = ErrorIngress::new(); + +const EMPTY: u8 = 0; +const WRITING: u8 = 1; +const READY: u8 = 2; +const READING: u8 = 3; + +struct ErrorIngress { + state: AtomicU8, + pending: UnsafeCell>, +} + +// SAFETY: `state` gives exactly one producer or consumer exclusive access to +// `pending`; neither side waits for a preempted owner. +unsafe impl Sync for ErrorIngress {} + +impl ErrorIngress { + const fn new() -> Self { + Self { + state: AtomicU8::new(EMPTY), + pending: UnsafeCell::new(MaybeUninit::uninit()), + } + } + + fn publish(&self, id: ErrorId, raised_at: &'static str) { + if self + .state + .compare_exchange(EMPTY, WRITING, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + return; + } + // SAFETY: the successful state transition grants this producer sole + // access until it publishes READY with release ordering. + unsafe { + (*self.pending.get()).write(ErrorRecord::new(id, raised_at)); + } + self.state.store(READY, Ordering::Release); + } + + fn take(&self) -> Option { + if self + .state + .compare_exchange(READY, READING, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + return None; + } + // SAFETY: READY proves that the sole producer initialized the record; + // READING grants this consumer exclusive access until EMPTY. + let record = unsafe { (*self.pending.get()).assume_init_read() }; + self.state.store(EMPTY, Ordering::Release); + Some(record) + } +} + +pub(crate) fn publish(id: ErrorId, raised_at: &'static str) { + GLOBAL_INGRESS.publish(id, raised_at); +} + +/// Takes the pending typed firmware error, if one exists. +pub fn take() -> Option { + GLOBAL_INGRESS.take() +} + +#[cfg(test)] +mod tests { + use super::ErrorIngress; + use crate::ErrorId; + + #[test] + fn retains_the_first_id_and_location_until_consumed() { + let ingress = ErrorIngress::new(); + ingress.publish(ErrorId::new(65402), "first.rs:10"); + ingress.publish(ErrorId::new(65403), "second.rs:20"); + + let first = ingress.take().expect("first error retained"); + assert_eq!(first.id(), 65402); + assert_eq!(first.raised_at(), "first.rs:10"); + assert!(ingress.take().is_none()); + + ingress.publish(ErrorId::new(65403), "second.rs:20"); + let second = ingress.take().expect("slot reusable after consumption"); + assert_eq!(second.id(), 65403); + assert_eq!(second.raised_at(), "second.rs:20"); + } +} diff --git a/firmware/libs/kaptan-err-propagate/src/lib.rs b/firmware/libs/kaptan-err-propagate/src/lib.rs new file mode 100644 index 0000000..82c5a9d --- /dev/null +++ b/firmware/libs/kaptan-err-propagate/src/lib.rs @@ -0,0 +1,41 @@ +//! Bounded typed firmware errors and function-boundary propagation. +//! +//! This crate owns error identity and ingress. It contains no logging levels, +//! filtering, MAVLink, board, filesystem, or clock dependency. +//! +//! `#[propagate]` reports at each annotated boundary. Annotate the terminal +//! boundary where an error enters diagnostics; nested annotated callers would +//! attempt to report the same propagation again. The bounded ingress retaining +//! only one pending error is backpressure, not semantic duplicate suppression. + +#![no_std] + +#[cfg(test)] +extern crate std; + +mod ingress; +mod record; +mod sink; + +pub use ingress::take; +pub use kaptan_err_propagate_macros::{KaptanError, propagate}; +pub use record::{ErrorId, ErrorRecord, ErrorReport}; +pub use sink::ErrorSink; + +/// Implementation details used only by macro expansions. +#[doc(hidden)] +pub mod __private { + use crate::ErrorReport; + + /// Reports an error only when it is the final result leaving an annotated + /// function. Borrowing preserves the exact result returned to the caller. + #[inline] + pub fn report_result( + result: &core::result::Result, + raised_at: &'static str, + ) { + if let Err(error) = result { + crate::ingress::publish(error.error_id(), raised_at); + } + } +} diff --git a/firmware/libs/kaptan-err-propagate/src/record.rs b/firmware/libs/kaptan-err-propagate/src/record.rs new file mode 100644 index 0000000..229ead6 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate/src/record.rs @@ -0,0 +1,68 @@ +//! Stable error IDs and bounded pending records. + +/// Stable nonzero identity for one typed firmware error variant. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(transparent)] +pub struct ErrorId(u16); + +impl ErrorId { + /// Creates one explicitly assigned error identity. + pub const fn new(id: u16) -> Self { + assert!(id != 0, "error ID zero is reserved"); + Self(id) + } + + /// Returns the compact wire representation. + pub const fn get(self) -> u16 { + self.0 + } +} + +/// Implemented by [`crate::KaptanError`] derives. +/// +/// Obtaining an ID never publishes, allocates, or changes safety state. +pub trait ErrorReport { + /// Returns the stable ID assigned to this error variant. + fn error_id(&self) -> ErrorId; +} + +impl ErrorReport for ErrorId { + fn error_id(&self) -> ErrorId { + *self + } +} + +/// One reported typed firmware error and its reporting boundary. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ErrorRecord { + id: ErrorId, + raised_at: &'static str, +} + +impl ErrorRecord { + pub(crate) const fn new(id: ErrorId, raised_at: &'static str) -> Self { + Self { id, raised_at } + } + + /// Stable machine-readable identity for the error type and variant. + pub const fn id(self) -> u16 { + self.id.get() + } + + /// Compile-time `file:line` of the propagation boundary that reported it. + pub const fn raised_at(self) -> &'static str { + self.raised_at + } +} + +#[cfg(test)] +mod tests { + use super::{ErrorId, ErrorRecord}; + + #[test] + fn record_keeps_only_the_explicit_id_and_reporting_boundary() { + let record = ErrorRecord::new(ErrorId::new(65004), "module.rs:42"); + assert_eq!(record.id(), 65004); + assert_eq!(record.raised_at(), "module.rs:42"); + } +} diff --git a/firmware/libs/kaptan-err-propagate/src/sink.rs b/firmware/libs/kaptan-err-propagate/src/sink.rs new file mode 100644 index 0000000..fa581a3 --- /dev/null +++ b/firmware/libs/kaptan-err-propagate/src/sink.rs @@ -0,0 +1,79 @@ +//! One-slot retry ownership for a typed-error sink. + +use crate::ErrorRecord; + +/// Retains one typed error until a lower sink accepts it. +/// +/// Typed errors are never filtered. A composition may give them priority over +/// best-effort logs without coupling the producer to its transport. +pub struct ErrorSink { + pending: Option, +} + +impl ErrorSink { + /// Creates an empty sink. + pub const fn new() -> Self { + Self { pending: None } + } + + /// Whether this sink can accept a record from composition-level fanout. + pub const fn is_empty(&self) -> bool { + self.pending.is_none() + } + + /// Retains one record, returning it unchanged when this sink is occupied. + pub fn publish_record(&mut self, record: ErrorRecord) -> Result<(), ErrorRecord> { + if self.pending.is_some() { + return Err(record); + } + self.pending = Some(record); + Ok(()) + } + + /// Attempts the pending record and clears it only when `submit` accepts it. + /// + /// Returns whether a record was attempted, independently of admission. + pub fn submit_next(&mut self, submit: impl FnOnce(&ErrorRecord) -> bool) -> bool { + let Some(record) = self.pending else { + return false; + }; + if submit(&record) { + self.pending = None; + } + true + } +} + +impl Default for ErrorSink { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::ErrorSink; + use crate::{ErrorId, ErrorRecord}; + + #[test] + fn retains_one_error_until_the_sink_accepts_it() { + while crate::take().is_some() {} + crate::ingress::publish(ErrorId::new(65401), "test.rs:10"); + + let mut sink = ErrorSink::new(); + let record = crate::take().expect("published error"); + sink.publish_record(record).unwrap(); + assert!(!sink.is_empty()); + assert!(sink.submit_next(|record| { + assert_eq!(*record, ErrorRecord::new(ErrorId::new(65401), "test.rs:10")); + false + })); + assert!(sink.submit_next(|record| { + assert_eq!(record.id(), 65401); + assert_eq!(record.raised_at(), "test.rs:10"); + true + })); + assert!(sink.is_empty()); + assert!(!sink.submit_next(|_| true)); + } +} diff --git a/firmware/libs/kaptan-err-propagate/tests/propagate.rs b/firmware/libs/kaptan-err-propagate/tests/propagate.rs new file mode 100644 index 0000000..e05b4ef --- /dev/null +++ b/firmware/libs/kaptan-err-propagate/tests/propagate.rs @@ -0,0 +1,149 @@ +use kaptan_err_propagate::{ErrorId, ErrorReport, KaptanError}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] +enum TestError { + #[kaptan_error(id = 65001)] + Failed, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] +enum OuterError { + #[kaptan_error(id = 65002)] + Converted(TestError), +} + +impl From for OuterError { + fn from(error: TestError) -> Self { + Self::Converted(error) + } +} + +fn fail() -> Result<(), TestError> { + Err(TestError::Failed) +} + +fn middle() -> Result<(), TestError> { + fail() +} + +#[kaptan_err_propagate::propagate] +fn succeeds() -> Result { + Ok(7) +} + +#[kaptan_err_propagate::propagate] +fn annotated_outer() -> Result<(), TestError> { + middle()?; + Ok(()) +} + +#[kaptan_err_propagate::propagate] +fn annotated_inner() -> Result<(), TestError> { + fail() +} + +fn unannotated_outer() -> Result<(), TestError> { + annotated_inner() +} + +#[kaptan_err_propagate::propagate] +fn handles_inner_error() -> Result<(), TestError> { + let _handled = fail().is_err(); + Ok(()) +} + +#[kaptan_err_propagate::propagate] +fn returns_explicit_error(fail_at_boundary: bool) -> Result<(), TestError> { + if fail_at_boundary { + return Err(TestError::Failed); + } + Ok(()) +} + +type AliasedResult = core::result::Result; + +#[kaptan_err_propagate::propagate] +fn returns_alias() -> AliasedResult<()> { + fail() +} + +#[kaptan_err_propagate::propagate] +fn reports_final_converted_error() -> Result<(), OuterError> { + fail()?; + Ok(()) +} + +#[kaptan_err_propagate::propagate] +fn reports_error_id() -> Result<(), ErrorId> { + Err(ErrorId::new(65003)) +} + +#[kaptan_err_propagate::propagate] +async fn async_boundary() -> Result<(), TestError> { + fail()?; + Ok(()) +} + +struct Service; + +impl Service { + #[kaptan_err_propagate::propagate] + fn method(&self, fail_at_boundary: bool) -> Result<(), TestError> { + if fail_at_boundary { + fail()?; + } + Ok(()) + } +} + +fn take_record() -> kaptan_err_propagate::ErrorRecord { + kaptan_err_propagate::take().expect("escaping error must be reported") +} + +#[test] +fn reports_only_final_errors_leaving_annotated_boundaries() { + while kaptan_err_propagate::take().is_some() {} + + assert_eq!(succeeds(), Ok(7)); + assert!(kaptan_err_propagate::take().is_none()); + + assert_eq!(handles_inner_error(), Ok(())); + assert!(kaptan_err_propagate::take().is_none()); + + assert_eq!(annotated_outer(), Err(TestError::Failed)); + assert_eq!(take_record().id(), TestError::Failed.error_id().get()); + + assert_eq!(unannotated_outer(), Err(TestError::Failed)); + assert_eq!(take_record().id(), TestError::Failed.error_id().get()); + + assert_eq!(returns_explicit_error(true), Err(TestError::Failed)); + assert_eq!(take_record().id(), TestError::Failed.error_id().get()); + + assert_eq!(returns_alias(), Err(TestError::Failed)); + assert_eq!(take_record().id(), TestError::Failed.error_id().get()); + + assert_eq!( + reports_final_converted_error(), + Err(OuterError::Converted(TestError::Failed)) + ); + assert_eq!( + take_record().id(), + OuterError::Converted(TestError::Failed).error_id().get() + ); + + assert_eq!(reports_error_id(), Err(ErrorId::new(65003))); + assert_eq!(take_record().id(), 65003); + + assert_eq!(Service.method(false), Ok(())); + assert!(kaptan_err_propagate::take().is_none()); + + let mut future = core::pin::pin!(async_boundary()); + let mut context = core::task::Context::from_waker(core::task::Waker::noop()); + assert_eq!( + core::future::Future::poll(future.as_mut(), &mut context), + core::task::Poll::Ready(Err(TestError::Failed)) + ); + let record = take_record(); + assert_eq!(record.id(), TestError::Failed.error_id().get()); + assert!(record.raised_at().contains("propagate.rs:")); +}