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:

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!();
}

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:

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 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 --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.

Select a repo