Unable to catch formatted strings using std::panic::set_hook()

I only need similar to the following to write some tests, and I need to test functions that invoke tokio::task and if assert!() is false in a task it will just silently fail.

This works if I use asserts without formatting strings e.g.
assert!(100 < 1, "main: comparison error");
but not with formatting e.g.
assert!(100 < 1, "main: comparison error {} < {}", 100, 1);

/*
[dependencies]
tokio = { version = "1", features = ["full"] }
*/

use std::sync::{Arc, Mutex};

use tokio::task;

#[tokio::main]
async fn main() {
    std::panic::set_hook(
        Box::new(|panic_info| {
            if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
                println!("panic occurred: {s:?}");
                // TODO: collect errors here in some shared resource
            } else {
                println!("formatted panic string not caught");
            }
        })
    );
        
    for i in 0..=25 {
        task::spawn(async move {
            let a = 3; let b = 27;
            assert!(a + b == 31, "task {}: comparision error: a = {}, b = {}", i, a, b);
        });
    }
    assert!(100 < 1, "main: comparision error");
}

I also have the follow up question.
Is this always gonna catch all panics? The set_hok() is called in main thread but what if tokio moves the task in a separate thread and task panics from there, is it gonna be caught by set_hook() still?

No. Building with panic = "abort" is a guaranteed way to avoid unwinding. But unwinding is not hierarchical, anyway. The panic hook can trivially be replaced by anything in the call stack, including within any dependency. Trivial demo: Rust Playground

So what is one to do about this? First, don't depend on catching unwinds. If your tests need to assert that a panic occurred, you might want to use #[should_panic] instead. This is how: Rust Playground

This isn't perfect because there are several panics from multiple tasks, and only the first is forwarded to the test harness. If you need to verify multiple panics like this, you will need to do your own panic introspection to ensure they all match the expected panic message.

4 Likes

Formatted panics use String, not &str. You need to check for both: rust/panicking.rs at master · rust-lang/rust · GitHub

6 Likes

What if I have only one call to set_hook() at the begining of the test function, would panics from tasks in other threads be also caught?

The panic hook is program-wide, not per-thread. As long as nothing else in your program calls std::panic::set_hook, std::panic::take_hook, std::panic::update_hook or std::panic::always_abort, and as long as your program has been compiled in a mode where panics unwind rather than aborting, and as long as your panic does not cross an FFI boundary, then your hook will catch all panics in today's Rust.

Note, though, that this is an implementation detail - a future version of Rust is permitted to turn some panics but not others into ones you cannot catch via the panic hook infrastructure. There might, for example, be a #[panic="abort"] attribute in future that lets certain functions declare that if they panic, the only acceptable outcome is to abort without invoking the panic handler. Or there might be a std::panic::abort_if_panic<T, F: FnOnce() -> T>(f: F) -> T that aborts immediately if the closure it's passed panics.

1 Like

Note that the panic hook set by panic::set_hook is invoked in the panicking frame, before unwinding and before handing the panic to the panic runtime. As such it's run whether an unwinding or aborting runtime is used. But of course, if panics are set to abort, it's futile to try to "collect" panic infos because the program is going to terminate immediately after running the hook anyway.

3 Likes

Nice, exactly what I wanted to know.
I only plan to use this in tests so no FFI and just one call to std::panic::set_hook per test function maybe reset it to default handler with std::panic::take_hook at the test function end.

Also good to know.
Thanks for the great answer I consider it also to be solution, I pinned the other one since it answers first question that matches title of this topic.

Yes that's it.
That String type detail escaped me.
Prints as it should, thanks guys.

1 Like

By default, tests run in multiple threads. You're better off not doing this.

3 Likes

That will blow up on you. Test functions are run one test per thread in the same process by the default test runner, so you'll have multiple different tests competing to control the process-global panic handler.

Instead, I would suggest that your tests should store the JoinHandle that a spawn returns, and at the end of the test, .await the JoinHandle then, if it's an error, check is_panic to see if the task panicked.

3 Likes

I agree, considering this is exactly what the second playground link in Unable to catch formatted strings using std::panic::set_hook() - #2 by parasyte does. :smiley:

2 Likes

Yes you are right, I would only use that in some test functions not all of them so I could mess up those other tests when I rethink about this better.
Thanks for the heads up.

1 Like

It sure would. :slightly_smiling_face:

Not remembering correctly that part of the Rust book I thought that test are run in sequential order which is clearly wrong.
I will definitely try to re design it using that approach.
Thanks again guys.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.