One of my personal philosophies is that we should start looking beyond C to reason and think about ABI details that include features that are not accessible in C. And unwinding is one component of that ABI. In practice, there are 4 ABIs for unwinding: Itanium, ARM EH (which is essentially Itanium with the formats changed, IIRC), setjmp/longjmp, and Windows SEH. I understand the Itanium ABI the best, so I’ll talk in terms of that, but I think the concepts involved are broadly similar.
From the perspective of the ABI, unwinding defines an exception that’s being thrown during the unwind. This exception has a code that identifies whose exception it is, and how to free it if it’s not your exception. This means we can distinguish between Rust exceptions and foreign exceptions, and we can define UB based on the two kinds. In addition, we also have the issue of the caller and the callee source languages as an orthogonal axis for decision making, so we have the following matrix to fill out: Caller source = {Rust, FFI} × Callee source = {Rust, FFI} × Exception source {Rust, FFI}. We also have to worry about the semantics of core::intrinsics::try
, but here we just have to worry about the exception kind.
Right now, (Rust, Rust, Rust) is the only tuple that is well-defined; everything else is undefined behavior. We can define the other tuples, perhaps requiring some attributes to make the choice:
- (Rust, Rust, FFI): Call destructors as appropriate (current semantics). We can leave it undefined if people think a future Rust compiler may want custom unwinding.
- (Rust, FFI, Rust): Probably the best semantics are UB (i.e., mark the external function as nounwind) unless an
#[unwind]
attribute is added. - (Rust, FFI, FFI): This is an interesting case because if this tuple is always UB, we don’t have to worry about the other FFI exception object cases. But I think there are use cases for being able to catch and handle FFI exceptions, or at least pass them through. Obviously, FFI exceptions are going to be difficult to expose as anything more complex than an opaque-you’re-on-your-own target blob. Like the (Rust, FFI, Rust) case, they should be nounwind by default unless
#[unwind]
. I also suspect thatcore::intrinsics::try
shouldn’t attempt to catch to FFI exceptions; there’d have to be a new facility to catch an FFI exception. And use cases such as catching C++ exceptions should be left to extension crates. - (FFI, Rust, Rust): I don’t think there is much harm in letting Rust exceptions escape. I can see concerns about ABI stability, but we can probably say that doing anything with the Rust exception other than rethrowing it or stopping execution is UB. (Itanium ABI outright states this). If we’re concerned about future versions wanting to define their own unwinding method, we can limit this to specially-marked functions.
- (FFI, Rust, FFI): The only thing that’s really different from the above is there may want to be a specific way to pass-thru FFI exceptions without catching.
- (FFI, FFI, Rust): As elaborated above, UB if you do anything other than continue throwing the exception or catch it and immediately destroy it using the normal process for destroying foreign exceptions. If the exception reaches the top of the stack, UB as well.
So a rough sketch of how you could implement an unwinding ABI in Rust:
- Add an attribute
#[unwind(native)]
. In the absence of this attribute, unwind semantics remain as they are today. If the target cannot guarantee the below semantics, use of this attribute is a compiler error. (Panic mode being abort is a vacuous implementation of the semantics, I think). - If an external function with no
#[unwind]
attribute is called, and it causes an unwind into Rust, UB. - If an external function with
#[unwind]
is called, it may cause an exception to propagate into Rust. If the caller is not marked#[unwind(native)]
, UB. If the caller is marked#[unwind(native)]
, and the exception was generated bypanic!
orstd::resume::resume_panic
, the behavior is as if it were thrown by the caller function (with the observable exception of maybe having extra stack frames). - If a Rust function is called by an external function, and is not marked by
#[unwind(native)]
, then UB. - If a Rust function is called by an external function, and is marked by
#[unwind(native)]
, then a Rust function further up the callstack may legally catch it withcatch_unwind
(as mentioned above). Any non-Rust code in the middle may only catch-and-destroy or rethrow the exception without modification. If no one catches the exception, abort. - Add a
std::ffi::ForeignException
struct that encapsulates a foreign exception. Dropping this object causes it to be destroyed. There is also a methodfn throw(self) -> !;
that causes a rethrow of this object. Obviously, this is!Send
and also definitely notUnwindSafe
. - Add platform-specific
std::os::ForeignExceptionExt
that gets access to things like the unwind code or SEH details. - Add a method
pub fn catch_foreign_exception<F: FnOnce() -> R + UnwindSafe, R>(f: F) -> Result<R, ForeignException>
. - A foreign exception may be thrown from an
#[unwind]
function into an#[unwind(native)]
function. This will cause a foreign exception to be thrown through Rust code. Destructors will run doing this unwind process. However,std::panic::catch_unwind
will not catch such an exception. Onlycatch_foreign_exception
may catch it. If this function falls out of Rust code not via an#[unwind(native)]
function, then UB.
This proposal makes cross-language unwinding strictly opt-in only. Rust -> C -> Rust and C++ -> Rust -> C++ exception flows would be handled transparently if opted into, and can only be done if the target unwind ABI allows for it. Converting between Rust and C++ or SEH exceptions would be possible but largely left to user crates to handle.