The question is what's the behaviour for destructors of local variables on stack frames when a panic happens, with no-unwind function frames, e.g. extern "C"
or #[rustc_nounwind]
. Additionally, the same MIR construct controls the behaviour when unwinding past cleanup blocks.
Note that when this scenario happens, if none of the destructors diverge, the process would be terminated with a no-unwind panic, similar to std::terminate
in C++. The discussion is about what destructors are to be executed before eventual process termination.
Example snippet:
struct Noise(&'static str);
impl Drop for Noise {
fn drop(&mut self) {
eprintln!("{}", self.0);
}
}
fn panic() {
let _val = Noise("foo");
panic!();
}
extern "C" fn test() {
let _val = Noise("bar");
panic();
}
fn main() {
test();
}
Another case where it's not allowed to unwind past is cleanup blocks, which we use similar MIR construct today:
struct Noise(&'static str);
impl Drop for Noise {
fn drop(&mut self) {
eprintln!("{}", self.0);
}
}
fn panic() {
let _val = Noise("foo");
panic!();
}
struct Baz;
impl Drop for Baz {
fn drop(&mut self) {
panic();
}
}
fn main() {
let _val = Noise("baz");
let _baz = Baz;
panic!();
}
When a panic happens with a extern "C"
frame on stack:
extern "C"
frame (all destructors of the unwound frames are executed).extern "C"
frame is not executed.This means that with the example above, foo
is printed but not bar
.
When a panic happens within cleanup (nested panic):
This means that with the cleanup example above, foo
is printed but not baz
.
This behaviour is similar to perform a manual catch_unwind
for each function call in such functions:
extern "C" fn foo() {
let _val = Noise("bar");
std::panic::catch_unwind(|| {
panic();
}).unwrap_or_else(|_| {
// Process will be terminated before _val is dropped.
panic_nounwind!()
});
std::panic::catch_unwind(|| {
drop(_val);
}).unwrap_or_else(|_| {
panic_nounwind!()
});
}
Details here: https://github.com/rust-lang/rust/issues/123231#issuecomment-2028182949
Summary: In C++, it is not specified on how many desturctors will be executed when an unwinding will eventually terminate due to an noexcept
frame.
Implementation-wise, when optimisation is not enabled, both GCC and Clang have similar behaviour to Rust today. Notably, Clang codegen as if all functions are called within a try-catch block with std::terminate
called in the catch block. A C++ equivalent of the above Rust example would show foo
being printed only.
GCC codegen this differently, instead omitting the callsite from the unwind action table, and this is interpreted by the personality function to terminate the process. When optimisation is enabled, GCC can propagate this to inlined callee and mark additional callsites in the callee frames as unwind-terminate, and therefore skipping additional destructors. The G++ equivalent of the above Rust example might print neither foo
nor bar
.
If foreign frames are present, it is difficult if not impossible for Rust to decide their behaviour. For example, if Rust panic traverses a C++ noexcept frame, it's up to the personality function of the C++ frame to decide what to happen.
In Itanium ABI, 2-phase unwinding exists, so if an exception is not to be caught, then the unwind would fail to initiate and none of the frames would be unwound and therefore no destructors are to be executed. This would make option 2 mentioned below more preferrable IMO because otherwise we may have a weird case where switching from extern "C-unwind"
to extern "C"
causes more frames to be unwound and more destructor to be executed.
Specify that it's unspecified if and what destructors are to be executed before termination.
Specify that function calls inside extern "C"
functions act like try {...} catch (...) { terminate(); }
.
This codifies today's behaviour.
Specify that a extern "C"
frames to act like try {...} catch (...) { terminate(); }
. This will mean that it will be treated as a catch frame.
extern "C"
to this option.
Ensures both foo
/bar
are printed. How about cleanup case?
Specify that extern "C"
frame introduces an nounwind context, and unwinding is disallowed within such contexts. Unwind contexts are reintroduced by catch_unwind
function.
panic!()
is to be treated as panic_nounwind!()
, which aborts the process without executing any destructor.extern "C"
frames in SEH.Ensures neither foo
/bar
are printed.
Itanium implementation would be (without diagnostic improvement):
diff --git a/library/std/src/sys/personality/gcc.rs b/library/std/src/sys/personality/gcc.rs
index f6b1844e153..55a50cd17a8 100644
--- a/library/std/src/sys/personality/gcc.rs
+++ b/library/std/src/sys/personality/gcc.rs
@@ -222,8 +222,8 @@ fn __gnu_unwind_frame(
if actions as i32 & uw::_UA_SEARCH_PHASE as i32 != 0 {
match eh_action {
EHAction::None | EHAction::Cleanup(_) => uw::_URC_CONTINUE_UNWIND,
- EHAction::Catch(_) | EHAction::Filter(_) => uw::_URC_HANDLER_FOUND,
- EHAction::Terminate => uw::_URC_FATAL_PHASE1_ERROR,
+ EHAction::Catch(_) => uw::_URC_HANDLER_FOUND,
+ EHAction::Filter(_) | EHAction::Terminate => uw::_URC_FATAL_PHASE1_ERROR,
}
} else {
match eh_action {
Specify that both option 1 and option 2 are allowed, and leave the door open for future decision.
Ensures either foo
/bar
are both printed and neither are.