track caller
by Sunday, 29 January 2023
onI've recently contributed a little bit to the Tokio project.
The first issue I worked on was #4413: polish: add #[track_caller]
to functions that can panic.
Now I'm going to tell you everything you didn't know that you didn't need to know about #[track_caller]
.
what
Before Rust 1.42.0 errors messages from calling unwrap()
weren't very useful.
You would get something like this:
thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', /.../src/libcore/macros/mod.rs:15:40
This tells you nothing about where unwrap
panicked.
This was improved for Option::unwrap()
and Result::unwrap()
in Rust 1.42.0.
More interestingly, the mechanism to do this was stabilised in Rust 1.46.0.
What was this mechanism? The track_caller
attribute.
how
Where would you use #[track_caller]
?
Imagine you're writing a library, it's called track_caller_demo
.
Here's the whole thing:
/// This function will return non-zero values passed to it.
///
/// ### Panics
///
/// This function will panic if the value passed is zero.
pub fn do_not_call_with_zero(val: u64) -> u64 {
if val == 0 {
panic!("We told you not to do that");
}
val
}
We have been quite clear - you MUST NOT pass zero to this function.
Now along comes a user of your crate and writes this code:
use track_caller_demo::do_not_call_with_zero;
fn code_written_by_crate_user() {
do_not_call_with_zero(0);
}
When the user runs their code, they'll see the following:
thread 'main' panicked at 'We told you not to do that', .cargo/registry/src/github.com-1ecc6299db9ec823/track_caller_demo-0.1.0/src/lib.rs:8:9
And the user says, "the crate author wrote buggy code!"
But we told them not to pass zero to that function.
We did it in multiples ways.
We don't want the user to see where the code panicked in our crate.
We want to show them their mistake.
So we annotate our function with #[track_caller]
:
/// This function will return non-zero values passed to it.
///
/// ### Panics
///
/// This function will panic if the value passed is zero.
#[track_caller]
pub fn do_not_call_with_zero(val: u64) -> u64 {
if val == 0 {
panic!("We told you not to do that");
}
val
}
Now the user will see the following error message instead:
thread 'main' panicked at 'We told you not to do that', src/bin/zero.rs:4:5
This shows the location in the user's code where they called our library incorrectly.
Success!
except
There is one caveat.
The track_caller
attribute must be on the whole call stack.
Every function from the panic, upwards.
Otherwise it won't work.
Let's add a new function to our library:
/// This function will return non-one values passed to it.
///
/// ### Panics
///
/// This function will panic if the value passed is one.
#[track_caller]
pub fn do_not_call_with_one(val: u64) -> u64 {
panic_on_bad_value(val, 1);
val
}
fn panic_on_bad_value(val: u64, bad: u64) {
if val == bad {
panic!("We told you not to provide bad value: {}", bad);
}
}
We annotate our public function with #[track_caller]
.
Let's check the output:
thread 'main' panicked at 'We told you not to do that', .cargo/registry/src/github.com-1ecc6299db9ec823/track_caller_demo-0.1.0/src/lib.rs:29:9
The panic is pointing at our perfectly good library code!
To make this work, annotate the whole stack:
/// This function will return non-one values passed to it.
///
/// ### Panics
///
/// This function will panic if the value passed is one.
#[track_caller]
pub fn do_not_call_with_one(val: u64) -> u64 {
panic_on_bad_value(val, 1);
val
}
#[track_caller]
fn panic_on_bad_value(val: u64, bad: u64) {
if val == bad {
panic!("We told you not to provide bad value: {}", bad);
}
}
Now we get:
thread 'main' panicked at 'We told you not to provide bad value: 1', src/bin/one.rs:4:5
Much better!
Most of the work on tokio#4413 was writing tests to ensure this didn't happen.
except except
OK, there's another caveat.
The track_caller
attribute doesn't work in some places.
It doesn't work on closures (rust#87417).
But it does newly work on async functions (rust#78840).
Although I can't seem to work out which version of Rust that's in.
one more thing
You can also make use of #[track_caller]
without a panic!
The same mechanism that panic uses to get the calling location is available for all.
It's called with std::panic::Location::caller()
.
This is used by the unstable tracing feature in Tokio.
It allows console to display the creation location for each task.
A simple example would be:
/// Calls (prints) the `name` together with the calling location.
#[track_caller]
pub fn call_me(name: &str) {
let caller = std::panic::Location::caller();
println!(
"Calling '{name}' from {file}:{line}",
name = name,
file = caller.file(),
line = caller.line(),
);
}
Because we're using #[track_caller]
, the panic location will give us where call_me
was called from.
If we call it twice in succession:
fn main() {
call_me("Baby");
call_me("Maybe");
}
We would get the output:
Calling 'Baby' from src/bin/extra.rs:4
Calling 'Maybe' from src/bin/extra.rs:6
And this trick also works multiple layers into your call stack.
As long as you remember to annotate every function on the way down.
Which is pretty cool.