How to "catch" and "re-throw" a panic? (for test code)

I'm writing some test code. And that test code is required to communicate with some external component. So, after each test, the external component may be in a "tainted" state and must be reset to a "clean" state, so that the next test will be able to run properly.

Now, from all that I have read, Rust's test framework does not have pre/post functions that will run before/after each test. So what I did is just call my "clean-up" function at the very end of each [test] function. The problem is: If a test panics, e.g. assertion fails, it will not get to the "clean-up" function, so that no clean-up is performed and therefore the next test(s) will fail too.

I have found that std::panic::catch_unwind can be used to "catch" a panic. This way I could catch the panic in [test] function and still perform the clean-up. But, after all, we don't want the panic in the test function to be caught! Or, at least, if a panic was caught, then it must be "re-thrown". Because, otherwise, the test framework won't notice that the test failed! What we want is try...finally behavior, where some code is executed regardless of whether there was a panic (exception) or not, but in the end a possible panic (exception) is still passed up the callstack.

Can this be done in Rust?

There's a great way to do that: Drop.

4 Likes

Is the drop() function of a local variable guaranteed to run, even when a panic occurs? :thinking:

You're talking about resume_unwind, which is the other half of catch_unwind. Run the test case inside catch_unwind, save the result, do the cleanup, then use resume_unwind to continue. Something like:

#[test]
fn crashy_test() {
    let res = std::panic::catch_unwind(|| do_crashy_test());
    clean_up_after_test();
    match res {
        Ok(ok) => it_didnt_panic(ok),
        Err(panic) => std::panic::resume_unwind(panic),
    }
}

Note, though, that this is manual and error prone. A better option is to use Drop to clean up at the end of a scope - unwinding runs Drop::drop for you:

struct Cleanup;
impl Drop for Cleanup {
    fn drop(&mut self) {
        clean_up_after_test();
    }
}

#[test]
fn crashy_test() {
    let _cleanup = Cleanup;
    let res = do_crashy_test();
    it_didnt_panic(res);
}
3 Likes

Yes. Panic is not so special. What you can't do, is panic inside drop() while panicing

2 Likes

So, I'm assuming that's a "yes"?

If so, then that is great and I think I will be able to use drop() :+1:

(Didn't even consider it may be this simple)

1 Like

What you can't do, is panic inside drop() while panicing

Does it mean that catch_unwind() shall be used inside the drop() function in order to ensure that no (uncaught) panic! can occur there?

No. You just should not write code that would panic. If it will, process will be aborted immediately.

1 Like

Yes - as part of leaving the scope, Drop::drop is run on all of your locals that are going out of scope, whether you're leaving due to clean exit, or due to a panic unwinding.

There's two gotchas to watch for (one doesn't apply here):

  1. Panicking while you're unwinding is an abort - if your Drop implementations panic, you'll not clean up fully. It's on you to audit your Drop implementations to ensure that they do not panic.
  2. I can configure my build to abort on panic without unwinding - if I do that, panics exit the program immediately, without running Drop on anything. But for tests, if you've done that, you get to keep all the pieces.
2 Likes

Bear in mind that catch_unwind() could be deprecated in the near future (if I understood correctly).

2 Likes

Bear in mind that catch_unwind() could be deprecated in the near future

But drop() of all "pending" objects is still going to be executed after a panic? :thinking:

With panic=abort, no, no drops will be executed at all.

1 Like

Yes, that's what differentiates a proper unwinding, controlled panic from an irrecoverable, uncontrolled crash.

1 Like

Not sure what you mean. Unless panic=abort is set, the act of unwinding itself causes the drop impls to run, as if on scope exit or upon a normal return. What's a "pending object"?

1 Like

Not sure what you mean. Unless panic=abort is set, the act of unwinding itself causes the drop impls to run, as if on scope exit or upon a normal return. What's a "pending object"?

With "pending object" I mean an object that implements Drop and that still waits for its drop() function to be called. If I understand correctly, the situation now is that when a panic happens, then "unwinding" occurs and thus all "pending" objects have their drop() called.

Well, unless panic=abort is set.

Then you said that unwinding is "considered harmful" and probably will be deprecated. So, will only explicit catch_unwind() go away, or undwinding as a whole? In the latter case, will "pending" objects then still get their drop() called, or will panic=abort be the new default?

(In other words, should I still rely on drop() to be called in new code?)

It's about changing the default to instant abort, yes.

You may, due to the backwards compatibility. Actually changing defaults will not be done silently and is unlikely to happen at all.

1 Like

That article is written forcefully for some reason, but unwinding isn't going away.

I don’t really think Rust should remove support for unwinding, of course. For one thing, there is backwards compatibility to consider. But for another, [...]

2 Likes

Yes. That's a critical part of not leaking memory when things panic, for example.

The nomicon has a nice example of this in RawVec - The Rustonomicon -- it separates dropping the elements and freeing the memory into two different types, so that the other drop will deallocate the buffer even if the first drop itself ends up panicking.

1 Like

This article has been written by Niko Matsakis, who's leader of the Language Team. Mara Bos (Library team leader) also made poll on Twitter about the subject, and the outcome was the majority of people (55%) was fine with abort.

Out of curiosity, where did you see that unwinding wasn't going away? Have they changed their mind more recently about it?

PS: The quote you show is incomplete and out of context. For a more reliable grasp of what's likely to come, read the conclusion.

According to the article, if/when they deprecate unwinding, they may still allow it in some functions (at a cost). From your description, I understand that the resources you wish to reset are in the test code, which could be set as "I still want unwinding there" without touching the production code. So it shouldn't be a problem.

Something else to consider, though it doesn't work in all cases: what I sometimes do is keeping a flag or a counter that tells whether a test failed or not / how many tests failed, and only do the assert at the end. It allows me to run several tests and see how many and which errors occurred before stopping the test. If you can do the same, you'll avoid all that unwinding stuff entirely. Obviously, that won't work if the panic comes from the production code.