Understanding UnwindSafe :)

hi folks! It's getting a bit difficult to wrap my head around this...

Simply put, a type T implements UnwindSafe if it cannot easily allow witnessing a broken invariant through the use of catch_unwind (catching a panic)

Does this mean that if the closure passed to catch_unwind captures T (by exclusive reference for example) and we panic! while the closure was executing and then outside of the closure we observed T in a broken state then Tis !Unwindsafe ?

also, does it make sense to propagate an error to the caller using this std::panic::catch_unwind(|| f())? ?

UnwindSafe doesn't do anything. It doesn't protect against anything real.

It's a purely speculative caution by Rust based on assumptions that panics are unexpected and when something unexpected happens then programs may perhaps - we don't know - leave some data in some inconsistent state. What that state looks like we don't know either. Rust itself is always safe against panics, and the trait doesn't change Rust's behavior. The trait is just "FYI" for applications to guard against some custom application-specific problem with unwinding, if they have one.

Because it's such a vague worry, Rust can't really point to anything specific and will be suspicious of every type that can mutate data that survives a panic.

I think that in practice this whole concept and the entire trait is useless.

It's a leftover from Erlang-inspired proto-Rust that wanted to have separate heaps per thread and Task that dies on panic with all of its data. In that language shared data would actually need special care to be unwind safe (avoid using per-thread GC heaps). But the Rust we have today has nothing like this. The whole idea didn't pan out and was abandoned, except that vestige.

All Rust code is already required to prevent Undefined Behavior when unwinding happens, even when it doesn't implement UnwindSafe. Rust code has deterministic Drop that runs on unwinding which is commonly used to fix or discard any incomplete data on unwinding. So the only reason for UnwindSafe would be having code where somebody cared just enough to make it not-unsafe when panicking, but didn't care enough to make it not buggy and left the bugs in intentionally. But even that doesn't work, because in practice every time a type doesn't implement UnwindSafe, it's because author of the code had no idea the trait existed. So even users who would like to intentionally use absence of UnwindSafe impl to mean something, can't rely on it.

the UnwindSafe bound is placed on the closure type itself. because the UnwindSafe marker is structural, if a closure captures &mut T, the UnwindSafe status of the closure type depends on &mut T, not T. and &mut T is already !UnwindSafe by default. T is UnwindSafe only matters when the closure captures T by value.

yes, but rarely. for instance, it might serve as an adapter between api boundaries.

usually, if you choose to catch the exception, you want to handle it at the point, or at least inspect/transform it in someway before "rethrow" it. otherwise, why bother to catch it instead of letting it propogate naturally?

although the question mark can convert the Err payload through the Into impl, it would be better to make the exception handling explicit, even if you rethrow it afterwards.

To wit, AssertUnwindSafe doesn't require unsafe and allows you to pass any closure to catch_unwind, so UnwindSafe cannot be load bearing.

More conversation here and in the linked PRs/issues.

*triggered*

Oo, that's very assuring! Thank you for the explanation.

Ah, so each &T, &mut T and T are different in this regard?

mhm, makes sense. All threads on panic nicely propagate the panic to the caller, right?

what do you mean by "load bearing"? thanks.

FWIT I use UnwindSafe trait in unsafe contracts: I kind of extend it in the safety requirement and use as a base. So I am not inventing a safety condition from scratch, but strengthening UnwindSafe. So I find it useful, a nice concept.

It can be bypassed safely. This means it doesn't give you any guarantees.

Example of something load bearing: in stable Rust, by the reference[1], &mut T is the unique reference to T. This means you can be 100% sure that if you have &mut T, no one has any &T or &mut T to this T. There is no way to get a second one safely, and if you do it unsafely, it is immediate UB and the program stops being a valid Rust program.


  1. rustc, an implementation of the reference, makes an exception for T: !Unpin, but this is beyond the scope. ↩︎

That's the problem : the compiler is only speculative when with just a tiny bit of good sense the problem can easily be identified. There's a legend that claim that exceptions (or panics, that's actually the same) can produce unexpected behaviours. Well while that's not false, that's not really true either because returning the error can also. Well on their own they don't cause any UB, exceptions/panics will only hide a programmers mistake that returning errors will show you, thus making it easy to avoid it, and easy to spot it if you ever commit it. But the risk of committing that mistake is not ruled out with a returning error.

so let's make things simple : what do you expect when you prematurely leave a function because some error occurred once a mutation has (possibly been) made ?

No it's not a vague worry. It actually should and only has to prohibit any possibility of BOTH throwing a panic AND returning a Result::Err after any possible mutation has been made, such that if something wrong occurs, expected or not, the objects are always left untouched.

so let's make things simple : what do you expect when you prematurely leave a function because some error occurred once a mutation has (possibly been) made ?

That the mutated value is still in some state that permits to call its safe functions, like Drop::drop. For UnwindSafe implementors, that the mutated value will work normally i.e. not deadlock, return sensible results, etc.

Say, if [&mut T]::sort would leave zeroed-out placeholders or copies of an original reference if the comparison panicked, the resulting slice would be UB to use.

That same worry would apply to a sorting function which returns whenever comparison fails or panics, without cleaning up whatever was moved. However, it's easier to spot if u.partial_cmp(v).is_none() { return; }[1] than the possible control flow divergence in if u < v { ... }: panics' distinction is that they autopropagate from other functions, and particularly from user-provided functions.


  1. Let's leave aside that PartialOrd::partial_cmp can panic too. ↩︎

As a practical example of the sort of function @ProgramCrafter is describing, there's Vec::retain_mut, which internally breaks some of the internal state of Vec, but has taken care to make sure that if the passed function panics, it repairs the internal state so that nothing goes wrong.

However, if there's a bug in that repair process that causes it to also panic, you've got a problem - and it's that sort of vague "what if there's a bug in clean-up code?" that UnwindSafe is worrying about - you "should" only be UnwindSafe if the clean-up code is bug-free in the face of panics.

If you've got C++ experience, UnwindSafe is an attempt at a compiler-checked assertion that the UnwindSafe thing is exception-safe, at least to the level of C++'s "basic exception safety guarantee". But, because it's not something the compiler can actually verify, it ends up being a bit of a mess; since most Rust code doesn't really think about panics (the culture has tended towards "a panic in the wild is a bug that needs fixing", and thus not caring about follow-on bugs that root-cause to "something previous panicked"), it's unusual for Rust developers to have considered unwind safety at all, unless they're also doing something unsafe (where it can be critical to avoiding UB).

the answer to my own question is : the mutated value will be in a hybrid state between the old and new one, which in most cases won't make any sense. For the rest of your post, I really don't understand how it's supposed to be relevant.

You don't have to repair anything if you don't break it in the first place. Just don't modify anything until everything that can fail has succeeded, even what is not supposed to fail.

The java compiler is capable of verifying it. It's called "checked exceptions". The rust compiler could infer panics without the need of declaring it in function signature.

checked exceptions in java ARE declared in the function signature

To everyone's chagrin. Probably one of the bigger mistakes in Java syntax honestly

I pointed out retain_mut very deliberately, because there's a tradeoff here: I cannot have both the best possible performance, and not do modifications until everything that can fail has succeeded.

You can implement retain_mut in a way that makes sure that there's no need to repair things on failure, but that then requires you to do O(removed) operations of shuffling items after a removed item towards the front of the Vec, to restore the Vec to being a contiguous slice.. The actual implementation of retain_mut reduces this to O(runs of removed items) shuffling, which is never slower than the implementation that doesn't break the invariants, but can be much faster if the items to be removed come in runs.

The Java compiler is not capable of verifying it; "checked exceptions" just tells you which functions can panic, and what exception they throw when they panic, but not whether the function's body correctly uses try/finally to ensure that invariants are restored before the function exits.

UnwindSafe is a different concept, known in C++ as "basic exception safety"; it's saying that, at the time the UnwindSafe closure exits via unwinding, all invariants are still upheld, even if something panicked deep inside the closure. This requires you to have (a) realised that you're breaking the invariants of your data structures, and (b) to have structured your code such that when an exception is thrown (or the code panics), the repair work needed to fix those invariants is done.

Taking retain_mut as an example; it breaks the "a Vec's valid contents form a contiguous slice" invariant by introducing a hole - there's a slice of valid items before the hole, and a second slice of valid items after the hole. The needed fix is to move the items after the hole to be contiguous with the items before the whole; or, looked at from a different direction, to move the hole so that it's at the end of the Vec; retain_mut sets things up so that when something panics, the impl Drop of a private struct (that exists only to allow it to be UnwindSafe after breaking the invariants of a Vec) fills in the hole with valid elements.

If you read the HashMap documentation, it describes a number of requirements which are assumed about the keys, and then says

The behavior resulting from either logic error is not specified, but will be encapsulated to the HashMap that observed the logic error and not result in undefined behavior. This could include panics, incorrect results, aborts, memory leaks, and non-termination.

The reason being that if undefined behavior resulted -- if it assumed the requirements to the point of relying on them to avoid UB -- it would be the fault of the HashMap implementation, not of whoever implemented the key, because it is possible to violate the HashMap key requirements without using unsafe.

Or similarly here,

Note that this trait is a safe trait and as such does not and cannot guarantee that the returned length is correct. This means that unsafe code must not rely on the correctness of Iterator::size_hint. The unstable and unsafe TrustedLen trait gives this additional guarantee.

And the same with UnwindSafe. It's trivially possible to make any closure UnwindSafe without using unsafe yourself, so others cannot rely on your closures meeting an UnwindSafe to avoid UB.

And if you have a situation where a panic leaves some data structure in a state where safe code can then cause UB, like some of the scenarios discussed above, it doesn't matter if the types involved are UnwindSafe or not: you have a soundness bug regardless.

UnwindSafe cannot prevent unsoundness on its own, it can only hint where more danger might be.

At the expense of debugging performance when something goes wrong.
If you lose the initial state, you know you have a bug, but all you know is

  • which invariant was broken so you know where you crashed, but not where you started from
  • a stack trace which doesn't give you much information and that sometimes can be misleading if somewhere in your code you prematurely leave a function with a broken invariant which will be unchecked and undetected or if a mistake has been made in checking preconditions.

If you keep the initial state, you can easily reproduce the bug, bloat the code with std::cout / println! / Console.Log or whatsoever and use a debugger and perform a line-by-line trace into.

For me it's the whole contrary : while Rust was ALLEGEDLY supposed to be designed for systems that must not fail (which is my case), the mentality in the language is to just let the application crash like a piece of shit. Not for me. That's what really feds me up. It would just force me to bloat my code with copies/clones which is what I already do in C++ and is completely overkill. When you know the typical naïve and stupid implementation of checking invariants - calling a function that checks everything in particular what hasn't changed - my method is often not slower than the a posteriori check everything implementation.

It actually doesn't verify it, but on contrary of the rust compiler, it has all the necessary information to. But fails to. At least for exceptions that fall in to the checked category.

by design of the language in Java, and actually only those expectable exceptions have to be checked.

In order to call all destructors of objects instantiated between the place panic occurred and the catch_unwind function, the compiler has to infer what destructors have to be called, and thus what are the functions that are on the panic/exception path. And thus has all the information to infer which functions transitively panic.

Can you explain how it can verify that no invariants are broken by throwing a checked exception? I can trivially come up with code that has invariants that can only be broken while holding a lock, and where throwing a checked exception in Java would result in the relevant lock being released with the invariant broken (which is not allowed, and which you're saying the Java compiler has all the necessary information to prevent).