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
8 changes: 8 additions & 0 deletions crates/core/src/error/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,4 +303,12 @@ where
}
}
}

#[cfg(feature = "anyhow")]
fn ext_into_anyhow(mut self) -> anyhow::Error {
match self.error.take() {
Some(error) => anyhow::Error::from(error).context(self.context),
None => anyhow::Error::msg(self),
}
}
Comment on lines +307 to +313
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I guess anyhow's is/downcast could never see through our context chains when all they have is a Box<dyn core::error::Error>. Okay, I get it.

}
43 changes: 42 additions & 1 deletion crates/core/src/error/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ pub(crate) unsafe trait ErrorExt: Send + Sync + 'static {
/// Take the backtrace from this error, if any.
#[cfg(feature = "backtrace")]
fn take_backtrace(&mut self) -> Option<Backtrace>;

/// Conversion into an `anyhow::Error`.
#[cfg(feature = "anyhow")]
fn ext_into_anyhow(self) -> anyhow::Error;
}

/// Morally a `dyn ErrorExt` trait object that holds its own vtable.
Expand Down Expand Up @@ -542,7 +546,7 @@ impl From<Error> for Box<dyn core::error::Error + 'static> {
impl From<Error> for anyhow::Error {
#[inline]
fn from(e: Error) -> Self {
anyhow::Error::from_boxed(e.into_boxed_dyn_error())
e.inner.into_anyhow()
Comment on lines -545 to +549
Copy link
Member

@fitzgen fitzgen Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make sure I understand, it isn't enough to change this impl into something like

e.downcast::<anyhow::Error>().unwrap_or_else(|e| {
    anyhow::Error::from_boxed(e.into_boxed_dyn_error())
})

and expose the underlying anyhow::Error (if any) instead of adding another dyn-boxing indirection because anyhow itself doesn't implement is/downcast/etc... for anyhow::Errors created via anyhow::Error::from_boxed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The specific case that I ran into updating Spin was that a Trap in Wasmtime was converted to wasmtime::Error and then that was converted to anyhow::Error. Calling anyhow_error.downcast::<Trap>() was failing due to being created from anyhow::Error::from_boxed which ends up severing any downcast methods.

So I believe what you've gisted will solve anyhow-and-back but wouldn't solve acquiring Wasmtime's error types which never went through anyhow

}
}

Expand Down Expand Up @@ -1241,6 +1245,11 @@ where
fn take_backtrace(&mut self) -> Option<Backtrace> {
None
}

#[cfg(feature = "anyhow")]
fn ext_into_anyhow(self) -> anyhow::Error {
anyhow::Error::new(self.0)
}
}

/// `ErrorExt` wrapper for types given to `Error::msg`.
Expand Down Expand Up @@ -1319,6 +1328,11 @@ where
fn take_backtrace(&mut self) -> Option<Backtrace> {
None
}

#[cfg(feature = "anyhow")]
fn ext_into_anyhow(self) -> anyhow::Error {
anyhow::Error::msg(self.0)
}
}

/// `ErrorExt` wrapper for `Box<dyn core::error::Error>`.
Expand Down Expand Up @@ -1374,6 +1388,11 @@ unsafe impl ErrorExt for BoxedError {
fn take_backtrace(&mut self) -> Option<Backtrace> {
None
}

#[cfg(feature = "anyhow")]
fn ext_into_anyhow(self) -> anyhow::Error {
anyhow::Error::from_boxed(self.0)
}
}

/// `ErrorExt` wrapper for `anyhow::Error`.
Expand Down Expand Up @@ -1430,6 +1449,11 @@ unsafe impl ErrorExt for AnyhowError {
fn take_backtrace(&mut self) -> Option<Backtrace> {
None
}

#[cfg(feature = "anyhow")]
fn ext_into_anyhow(self) -> anyhow::Error {
self.0
}
}

pub(crate) enum OomOrDynErrorRef<'a> {
Expand Down Expand Up @@ -1754,6 +1778,23 @@ impl OomOrDynError {
}
}

#[cfg(feature = "anyhow")]
pub(crate) fn into_anyhow(self) -> anyhow::Error {
if self.is_oom() {
// Safety: `self.is_oom()` is true.
anyhow::Error::from(unsafe { *self.unchecked_oom() })
} else {
debug_assert!(self.is_boxed_dyn_error());
// Safety: this is a boxed dyn error.
let ptr = unsafe { self.unchecked_into_dyn_error() };
// Safety: invariant of the type that the pointer is valid.
let vtable = unsafe { ptr.as_ref().vtable };
// Safety: the pointer is valid and the vtable is associated with
// this pointer's concrete error type.
unsafe { (vtable.into_anyhow)(ptr) }
}
}

/// Given that this is known to be an instance of the type associated with
/// the given `TypeId`, do an owning-downcast to that type, writing the
/// result through the given `ret_ptr`, and deallocating `self` along the
Expand Down
17 changes: 17 additions & 0 deletions crates/core/src/error/vtable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ pub(crate) struct Vtable {
/// Upon successful return, a `T` will have been written to that memory
/// block.
pub(crate) downcast: unsafe fn(OwnedPtr<DynError>, TypeId, NonNull<u8>),

/// Conversion into `anyhow::Error` from `Box<Self>`.
#[cfg(feature = "anyhow")]
pub(crate) into_anyhow: unsafe fn(OwnedPtr<DynError>) -> anyhow::Error,
}

impl Vtable {
Expand All @@ -63,6 +67,8 @@ impl Vtable {
into_boxed_dyn_core_error: into_boxed_dyn_core_error::<E>,
drop_and_deallocate: drop_and_deallocate::<E>,
downcast: downcast::<E>,
#[cfg(feature = "anyhow")]
into_anyhow: into_anyhow::<E>,
}
}
}
Expand Down Expand Up @@ -174,3 +180,14 @@ where
}
}
}

#[cfg(feature = "anyhow")]
unsafe fn into_anyhow<E>(error: OwnedPtr<DynError>) -> anyhow::Error
where
E: ErrorExt,
{
let error = error.cast::<ConcreteError<E>>();
// Safety: implied by all vtable functions' safety contract.
let error = unsafe { error.into_box() };
error.error.ext_into_anyhow()
}
37 changes: 37 additions & 0 deletions crates/core/tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -905,3 +905,40 @@ fn oom_requested_allocation_size() {
let oom = OutOfMemory::new(usize::MAX);
assert!(oom.requested_allocation_size() <= usize::try_from(isize::MAX).unwrap());
}

#[test]
#[cfg(feature = "anyhow")]
fn anyhow_preserves_downcast() -> Result<()> {
{
let e: Error = TestError(1).into();
assert!(e.downcast_ref::<TestError>().is_some());
let e: anyhow::Error = e.into();
assert!(e.downcast_ref::<TestError>().is_some());
}
{
let e = Error::from(TestError(1)).context("hi");
assert!(e.downcast_ref::<TestError>().is_some());
assert!(e.downcast_ref::<&str>().is_some());
let e: anyhow::Error = e.into();
assert!(e.downcast_ref::<TestError>().is_some());
assert!(e.downcast_ref::<&str>().is_some());
}
Ok(())
}
Comment on lines +911 to +927
Copy link
Member

@fitzgen fitzgen Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes me realize wasmtime::Error probably doesn't handle is/downcast* correctly when created from an anyhow::Error that has a context chain:

let e = anyhow::Error::from(TestError(1));
let e = e.context("str");
let e = wasmtime::Error::from(e);
assert!(e.is::<anyhow::Error>()); // should pass right now
assert!(e.is::<TestError>());     // I think will fail
assert!(e.is::<&str>());          // I think will also fail

And in fact, I don't think we can support this, given that our vtable methods can't be generic over E but instead must take TypeIds. But maybe I am missing something and you have other ideas?

I guess we could at the wasmtime::Error implementation level fall back to something like

impl wasmtime::Error {
    pub fn is<E>(&self) -> bool {
        let result = existing_is_impl();
        
        #[cfg(feature = "anyhow")]
        let result = result || self.downcast_ref::<anyhow::Error>().is_some_and(|e| {
            e.is::<E>()
        });

        result
    }
}

?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point, and yeah I think hooking is and downcast and friends with type parameters to explicitly handle the anyhow special case is the only way to go here. I'll see if I can't hook that up.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point, and yeah I think hooking is and downcast and friends with type parameters to explicitly handle the anyhow special case is the only way to go here. I'll see if I can't hook that up.

Fine to do as a follow up too, ofc, if you prefer

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm ok yeah this is going to be a little gnarly, so I've added a test for the current behavior documenting that it's a bit buggy, but it at least preserves the anyhow roundtrip. I'll file a follow-up issue for this.


#[test]
#[cfg(feature = "anyhow")]
fn anyhow_source_loses_downcast() -> Result<()> {
let e: anyhow::Error = TestError(1).into();
assert!(e.downcast_ref::<TestError>().is_some());

let e: Error = Error::from_anyhow(e);
// FIXME: this should actually test for `is_some`
assert!(e.downcast_ref::<TestError>().is_none());

// Even while the above is broken, when going back to `anyhow` we should
// preserve the original error.
let e: anyhow::Error = e.into();
assert!(e.downcast_ref::<TestError>().is_some());
Ok(())
}