Rust is a programming language known for its memory safety. The compiler forces you to think about memory allocation and usage upfront such that errors are guaranteed not to appear at runtime.
Before we continue, it's worth noting that Rust is only safe against certain kinds of failures, specifically memory-corrupting crashes. This article does not intend to call out a design failure in Rust but instead walks through a subtle and interesting behaviour.
At hx, we have a Rust lecture series that new joiners to the backend team are encouraged to work through if they wish to learn and get familiar with the language. Alongside these recorded lectures are exercises to help solidify what was absorbed via the recordings. While working through one of these exercises, I came across an error I never thought I would see when working with Rust:
It was emotional. Was I such a bad programmer that I caused Rust to throw the one thing it tries its absolute hardest to avoid? At this point it was possible. But, after taking a breather, making a cup of tea, I came back and noticed that it was happening when I was trying to print my enums. Minimised to a compiling example, the stack overflow was being caused by the following:
Can you spot the bug? Looking at the above code it feels like it is going to be in the fmt implementation. But…where? Traditionally when diagnosing a stack overflow you’d look for memory leaks, infinite loops, etc. Here we have one line of code.
println!() only supports formatting types that implement the Display trait. Out of the box, an enum won’t unless you specify an implementation as done above. To decide on one, I hovered over the fmt method in IntelliJ and just copied the example it gave (because, well, what could go wrong if the borrow checker was ok with it?)
I made an assumption here that write!() can take self (an enum, in my head) and format it. I was wrong. It full on just exploded at runtime. Stack overflow. Done.
To dig deeper, we’ll have to look at what these macros are doing. Let’s use the compiler to expand them via cargo expand
Ah…in Example::fmt we’re calling Example::fmt again by asking for &[::core::fmt::ArgumentV1::new_display(&self)] (after all that's what the write! call expands too!). This is creating an infinite recursion that causes a stack overflow. So what’s the fix for this?
While the type itself did implement Display, it is our job to provide implementation for the actual values the type can have, i.e. variants. That part was missing, hence why the tail recursion occurred. Pattern matching the enum allows us to provide something we can actually format, a &str. It may seem simple in theory, but these macros hid a very subtle behaviour that was rather hard to spot!
Is There Tooling To Check For This?
Clippy is a collection of lints you can run against your Rust code to catch common mistakes, and helpfully catches this exact case! Having linters in your pipeline is a great way to catch otherwise easy-to-miss bugs. Running Clippy on the broken code-snippet will produce an output like the following:
Looking at what happened here, we promised Display was implemented for the type Example, but inside the implementation we rely on the implementation we said we would use.
This tail recursion is fully down to us “holding it wrong” in a way that could be quite hard for the compiler to spot. For all of the guarantees Rust gives us, it does show that you can never be too careful and fully rely on pre-runtime checks! Hopefully this will change in the future as the compiler improves, and there is a great and active community around Rust that provides tools to help us be even safer.