Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

## [0.16.0] - 2025-09-26

### Changed
- Switched the internal `AppError` source storage to `Arc<dyn Error>` and added a
shared `with_source_arc` helper so conversions can reuse existing `Arc`
handles without extra allocations.
- Replaced the backtrace slot with an `Option<Backtrace>` managed through an
environment-aware lazy capture that respects `RUST_BACKTRACE` and avoids
snapshot allocation when disabled.
- Updated the `masterror::Error` derive and `ResultExt` conversions to forward
sources using the new shared storage while preserving error chains.

### Tests
- Added regression coverage for the `std::error::Error` chain, `Arc` source
preservation in the derives, and conditional backtrace capture driven by the
`RUST_BACKTRACE` environment variable.

## [0.15.0] - 2025-09-25

### Added
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "masterror"
version = "0.15.0"
version = "0.16.0"
rust-version = "1.90"
edition = "2024"
license = "MIT OR Apache-2.0"
Expand Down Expand Up @@ -75,7 +75,7 @@ tonic = ["dep:tonic"]
openapi = ["dep:utoipa"]

[workspace.dependencies]
masterror-derive = { version = "0.7.0" }
masterror-derive = { version = "0.7.1" }
masterror-template = { version = "0.3.6" }

[dependencies]
Expand Down
15 changes: 7 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes.

~~~toml
[dependencies]
masterror = { version = "0.15.0", default-features = false }
masterror = { version = "0.16.0", default-features = false }
# or with features:
# masterror = { version = "0.15.0", features = [

# masterror = { version = "0.16.0", features = [
# "axum", "actix", "openapi", "serde_json",
# "tracing", "metrics", "backtrace", "sqlx",
# "sqlx-migrate", "reqwest", "redis", "validator",
Expand Down Expand Up @@ -79,10 +78,10 @@ masterror = { version = "0.15.0", default-features = false }
~~~toml
[dependencies]
# lean core
masterror = { version = "0.15.0", default-features = false }
masterror = { version = "0.16.0", default-features = false }

# with Axum/Actix + JSON + integrations
# masterror = { version = "0.15.0", features = [
# masterror = { version = "0.16.0", features = [
# "axum", "actix", "openapi", "serde_json",
# "tracing", "metrics", "backtrace", "sqlx",
# "sqlx-migrate", "reqwest", "redis", "validator",
Expand Down Expand Up @@ -720,13 +719,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED");
Minimal core:

~~~toml
masterror = { version = "0.15.0", default-features = false }
masterror = { version = "0.16.0", default-features = false }
~~~

API (Axum + JSON + deps):

~~~toml
masterror = { version = "0.15.0", features = [
masterror = { version = "0.16.0", features = [
"axum", "serde_json", "openapi",
"sqlx", "reqwest", "redis", "validator", "config", "tokio"
] }
Expand All @@ -735,7 +734,7 @@ masterror = { version = "0.15.0", features = [
API (Actix + JSON + deps):

~~~toml
masterror = { version = "0.15.0", features = [
masterror = { version = "0.16.0", features = [
"actix", "serde_json", "openapi",
"sqlx", "reqwest", "redis", "validator", "config", "tokio"
] }
Expand Down
2 changes: 1 addition & 1 deletion masterror-derive/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "masterror-derive"
rust-version = "1.90"
version = "0.7.0"
version = "0.7.1"
edition = "2024"
license = "MIT OR Apache-2.0"
repository = "https://github.com/RAprogramm/masterror"
Expand Down
13 changes: 13 additions & 0 deletions masterror-derive/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,19 @@ pub(crate) fn option_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
})
}

pub(crate) fn is_arc_type(ty: &syn::Type) -> bool {
let syn::Type::Path(path) = ty else {
return false;
};
if path.qself.is_some() {
return false;
}
path.path
.segments
.last()
.is_some_and(|segment| segment.ident == "Arc")
}

pub(crate) fn is_backtrace_type(ty: &syn::Type) -> bool {
let syn::Type::Path(path) = ty else {
return false;
Expand Down
19 changes: 17 additions & 2 deletions masterror-derive/src/masterror_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ use quote::{format_ident, quote};
use syn::{Error, Expr, ExprPath, Index};

use crate::input::{
ErrorData, ErrorInput, Field, Fields, MasterrorSpec, StructData, VariantData, is_option_type
ErrorData, ErrorInput, Field, Fields, MasterrorSpec, StructData, VariantData, is_arc_type,
is_option_type, option_inner_type
};

pub fn expand(input: &ErrorInput) -> Result<TokenStream, Error> {
Expand Down Expand Up @@ -433,13 +434,27 @@ fn source_attachment_tokens(bound_fields: &[BoundField<'_>]) -> TokenStream {
for bound in bound_fields {
if bound.field.attrs.has_source() {
let binding = &bound.binding;
if is_option_type(&bound.field.ty) {
let ty = &bound.field.ty;
if is_option_type(ty) {
let arc_inner = option_inner_type(ty).is_some_and(is_arc_type);
if arc_inner {
return quote! {
if let Some(source) = #binding {
__masterror_error = __masterror_error.with_source_arc(source);
}
};
}
return quote! {
if let Some(source) = #binding {
__masterror_error = __masterror_error.with_source(source);
}
};
} else {
if is_arc_type(ty) {
return quote! {
__masterror_error = __masterror_error.with_source_arc(#binding);
};
}
return quote! {
__masterror_error = __masterror_error.with_source(#binding);
};
Expand Down
145 changes: 119 additions & 26 deletions src/app_error/core.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
#[cfg(feature = "backtrace")]
use std::sync::OnceLock;
use std::{
backtrace::Backtrace,
env,
sync::{
OnceLock,
atomic::{AtomicU8, Ordering as AtomicOrdering}
}
};
use std::{
borrow::Cow,
error::Error as StdError,
fmt::{Display, Formatter, Result as FmtResult},
ops::{Deref, DerefMut},
sync::atomic::{AtomicBool, Ordering}
sync::{
Arc,
atomic::{AtomicBool, Ordering}
}
};

#[cfg(feature = "tracing")]
Expand Down Expand Up @@ -50,9 +59,7 @@ impl BacktraceSlot {
}

fn capture_if_absent(&self) -> Option<&Backtrace> {
self.cell
.get_or_init(|| Some(Backtrace::capture()))
.as_ref()
self.cell.get_or_init(capture_backtrace_snapshot).as_ref()
}
}

Expand All @@ -64,7 +71,19 @@ impl Default for BacktraceSlot {
}

#[cfg(not(feature = "backtrace"))]
type BacktraceSlot = Option<Backtrace>;
#[derive(Debug, Default)]
struct BacktraceSlot {
_marker: ()
}

#[cfg(not(feature = "backtrace"))]
impl BacktraceSlot {
fn set(&mut self, _backtrace: std::backtrace::Backtrace) {}

fn capture_if_absent(&self) -> Option<&std::backtrace::Backtrace> {
None
}
}

#[derive(Debug)]
#[doc(hidden)]
Expand All @@ -83,11 +102,71 @@ pub struct ErrorInner {
pub retry: Option<RetryAdvice>,
/// Optional authentication challenge for `WWW-Authenticate`.
pub www_authenticate: Option<String>,
source: Option<Box<dyn StdError + Send + Sync + 'static>>,
source: Option<Arc<dyn StdError + Send + Sync + 'static>>,
backtrace: BacktraceSlot,
telemetry_dirty: AtomicBool
}

#[cfg(feature = "backtrace")]
const BACKTRACE_STATE_UNSET: u8 = 0;
#[cfg(feature = "backtrace")]
const BACKTRACE_STATE_ENABLED: u8 = 1;
#[cfg(feature = "backtrace")]
const BACKTRACE_STATE_DISABLED: u8 = 2;

#[cfg(feature = "backtrace")]
static BACKTRACE_STATE: AtomicU8 = AtomicU8::new(BACKTRACE_STATE_UNSET);

#[cfg(feature = "backtrace")]
fn capture_backtrace_snapshot() -> Option<Backtrace> {
if should_capture_backtrace() {
Some(Backtrace::capture())
} else {
None
}
}

#[cfg(feature = "backtrace")]
fn should_capture_backtrace() -> bool {
match BACKTRACE_STATE.load(AtomicOrdering::Acquire) {
BACKTRACE_STATE_ENABLED => true,
BACKTRACE_STATE_DISABLED => false,
_ => {
let enabled = detect_backtrace_preference();
BACKTRACE_STATE.store(
if enabled {
BACKTRACE_STATE_ENABLED
} else {
BACKTRACE_STATE_DISABLED
},
AtomicOrdering::Release
);
enabled
}
}
}

#[cfg(feature = "backtrace")]
fn detect_backtrace_preference() -> bool {
match env::var_os("RUST_BACKTRACE") {
None => false,
Some(value) => {
let value = value.to_string_lossy();
let trimmed = value.trim();
if trimmed.is_empty() {
return false;
}
let lowered = trimmed.to_ascii_lowercase();
!(matches!(lowered.as_str(), "0" | "off" | "false"))
}
}
}

#[cfg(all(test, feature = "backtrace"))]
pub(crate) fn reset_backtrace_preference() {
BACKTRACE_STATE.store(BACKTRACE_STATE_UNSET, AtomicOrdering::Release);
}

/// Rich application error preserving domain code, taxonomy and metadata.
#[derive(Debug)]
pub struct Error {
Expand Down Expand Up @@ -117,8 +196,13 @@ impl Display for Error {
impl StdError for Error {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
self.source
.as_ref()
.map(|source| &**source as &(dyn StdError + 'static))
.as_deref()
.map(|source| source as &(dyn StdError + 'static))
}

#[cfg(feature = "backtrace")]
fn backtrace(&self) -> Option<&Backtrace> {
self.capture_backtrace()
}
}

Expand Down Expand Up @@ -173,26 +257,14 @@ impl Error {
self.telemetry_dirty.swap(false, Ordering::AcqRel)
}

#[cfg(feature = "backtrace")]
fn capture_backtrace(&self) -> Option<&Backtrace> {
fn capture_backtrace(&self) -> Option<&std::backtrace::Backtrace> {
self.backtrace.capture_if_absent()
}

#[cfg(not(feature = "backtrace"))]
fn capture_backtrace(&self) -> Option<&Backtrace> {
self.backtrace.as_ref()
}

#[cfg(feature = "backtrace")]
fn set_backtrace_slot(&mut self, backtrace: Backtrace) {
fn set_backtrace_slot(&mut self, backtrace: std::backtrace::Backtrace) {
self.backtrace.set(backtrace);
}

#[cfg(not(feature = "backtrace"))]
fn set_backtrace_slot(&mut self, backtrace: Backtrace) {
self.backtrace = Some(backtrace);
}

pub(crate) fn emit_telemetry(&self) {
if self.take_dirty() {
#[cfg(feature = "backtrace")]
Expand Down Expand Up @@ -331,14 +403,35 @@ impl Error {
/// Attach a source error for diagnostics.
#[must_use]
pub fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self {
self.source = Some(Box::new(source));
self.source = Some(Arc::new(source));
self.mark_dirty();
self
}

/// Attach a shared source error without cloning the underlying `Arc`.
///
/// # Examples
///
/// ```rust
/// use std::sync::Arc;
///
/// use masterror::{AppError, AppErrorKind};
///
/// let source = Arc::new(std::io::Error::new(std::io::ErrorKind::Other, "boom"));
/// let err = AppError::internal("boom").with_source_arc(source.clone());
/// assert!(err.source_ref().is_some());
/// assert_eq!(Arc::strong_count(&source), 2);
/// ```
#[must_use]
pub fn with_source_arc(mut self, source: Arc<dyn StdError + Send + Sync + 'static>) -> Self {
self.source = Some(source);
self.mark_dirty();
self
}

/// Attach a captured backtrace.
#[must_use]
pub fn with_backtrace(mut self, backtrace: Backtrace) -> Self {
pub fn with_backtrace(mut self, backtrace: std::backtrace::Backtrace) -> Self {
self.set_backtrace_slot(backtrace);
self.mark_dirty();
self
Expand All @@ -353,7 +446,7 @@ impl Error {
/// Borrow the backtrace, capturing it lazily when the `backtrace` feature
/// is enabled.
#[must_use]
pub fn backtrace(&self) -> Option<&Backtrace> {
pub fn backtrace(&self) -> Option<&std::backtrace::Backtrace> {
self.capture_backtrace()
}

Expand Down
Loading
Loading