owned this note
owned this note
Published
Linked with GitHub
# Cleanup behaviour when panic happens with no-unwind function frames
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:
```rust
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:
```rust
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!();
}
```
## Current Behaviour
When a panic happens with a `extern "C"` frame on stack:
* Unwind happens until the `extern "C"` frame (all destructors of the unwound frames are executed).
* Process is terminated (no-unwind panic).
* The destructor on the `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):
* On platforms with Itanium ABI:
* Unwind happens until the destructor call in the cleanup context.
* Process is terminated (no-unwind panic).
* Other destructor in the cleanups are not executed.
* On platforms with MSVC SEH:
* Triggering nested unwind creates immediate abort (by SEH runtime).
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:
```rust!
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!()
});
}
```
## C++ Behaviour
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`.
## Possible Behaviours
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.
### Option -1
Specify that it's unspecified if and what destructors are to be executed before termination.
* Identical to C++
* Allow current behaviour
* Max flexibility?
### Option 0
Specify that function calls inside `extern "C"` functions act like `try {...} catch (...) { terminate(); }`.
This codifies today's behaviour.
* May cause confusion?
### Option 1
Specify that a `extern "C"` frames to act like `try {...} catch (...) { terminate(); }`. This will mean that it will be treated as a catch frame.
* [#129582](https://github.com/rust-lang/rust/pull/129582) would adjust the current behaviour w.r.t. to `extern "C"` to this option.
* This PR doesn't change the behaviour for cleanup blocks. Particularly, trying to initiate a panic in cleanup block would cause immediate abort in SEH.
* The try/catch behaviour is implementable in SEH but would cause a lot excessive codegen.
* This will prohibit potential future optimisation that would allow landing pads to be eliminated.
Ensures both `foo`/`bar` are printed. How about cleanup case?
### Option 2
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.
* Useful for providing a core dump with the exception-site preserved.
* This model is helpful for optimisation, because the nounwind property propagates into inlined functions and allow elimination of additional landing pads.
* This is implementable in Itanium ABI by changing the personality function to return an error in phase 1.
* If we go down this route we can also improve diagnostics to show which frame is nounwind when a panic happens with a nounwind frame on stack.
* This is the similar to entering a cleanup block in MSVC SEH.
* However, it's unclear if we can implement this behaviour for `extern "C"` frames in SEH.
Ensures neither `foo`/`bar` are printed.
Itanium implementation would be (without diagnostic improvement):
```diff
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 {
```
### Option 1.5
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.