When Rust Drops Things in an Order You Didn't Expect
Here's a struct that looks entirely reasonable, and leaks half a temp directory on Windows:
use std::fs::{self, File};
use std::path::PathBuf;
struct TempDir(PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
struct Session {
dir: TempDir,
file: File,
}
A Session owns a scratch directory and a file inside it. When the session goes out of scope, both need to disappear: the file handle closes, the directory gets deleted. Nothing about the code above tells you which happens first.
It turns out that matters, because on Windows you cannot delete a directory while something inside it still has an open handle. If dir is cleaned up before file, remove_dir_all fails with a sharing violation and leaves half a temp directory behind.
On Linux this same code works fine, because Unix lets you unlink things that are still open. That's exactly the kind of platform difference that turns a struct field order into a real bug rather than a theoretical one.
The fix, once you know where to look, is to swap the two fields. That only makes sense once you understand the rule that got you here, because it isn't the rule most people assume.
Locals and fields drop in opposite directions
Ask most Rust programmers how drop order works and they'll say "reverse order of declaration, like a stack." That's correct, but only for one specific case: local variables (and function parameters) in a scope.
It is not true for the fields of a struct, tuple, or enum variant. Those drop in declaration order, forwards, the same order you wrote them in the type definition. This is documented explicitly in the Rust reference's section on destructors, and it is easy to demonstrate:
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("dropping {}", self.0);
}
}
fn main() {
let _first = Guard("first");
let _second = Guard("second");
let _third = Guard("third");
}
This prints third, then second, then first. Locals unwind in reverse, last-declared-first-dropped, which matches the mental model of a stack and is why most people never question it. Now take exactly the same type and put it inside a struct instead:
struct Bundle {
first: Guard,
second: Guard,
third: Guard,
}
fn main() {
let _bundle = Bundle {
first: Guard("first"),
second: Guard("second"),
third: Guard("third"),
};
}
This prints first, then second, then third, in the order the fields were declared. Same Drop impl, same construction order, opposite result. Nothing in the syntax warns you that moving a value from "local variable" to "struct field" flips the order it gets cleaned up in.
The rule generalises the same way for anything that owns multiple values:
- tuples drop index 0 first;
- arrays and slices drop element 0 first;
- a
Vec<T>drops its elements from the front.
The useful mental split: bindings in a scope unwind like a stack (last in, first out); the contents of a single value drop in the order they're written (first in, first out). Two rules, and they're mirror images of each other.
Swapping two fields fixes the temp directory
With that in mind, the Session struct's bug is obvious. dir is declared before file, so dir is dropped first, which means the temp directory removal runs while the file handle is still open. Swapping the declaration order fixes it:
struct Session {
file: File,
dir: TempDir,
}
Now file drops first, closing the handle, then dir removes the (now unheld) directory. That's the whole fix, and it's a one-line diff once you know the rule.
The reason this is worth dwelling on isn't the fix. It's that the bug is invisible in review unless you already know to check field order against resource dependencies. The same shape of problem turns up with:
- a
MutexGuardnext to a type whoseDropimpl expects the lock already released; - a database connection next to a transaction handle;
- a file descriptor next to an epoll registration that references it.
All silent until they run on the one platform or the one timing window where the ordering actually bites.
When reordering isn't enough, take control explicitly
Reordering works as long as there's a single fixed order that's always correct. Sometimes there isn't, for instance when the right order depends on a runtime condition, or when you need finer control than "before" and "after" for exactly two fields. In that case, wrap the fields in Option and drop them explicitly in your own Drop impl:
struct Session {
file: Option<File>,
dir: Option<TempDir>,
}
impl Drop for Session {
fn drop(&mut self) {
// Explicit order, independent of field declaration order.
self.file.take();
self.dir.take();
}
}
Option::take replaces the field with None and returns the old value, which is then dropped immediately as a temporary.
This costs you an Option wrapper and a couple of .take() calls. In return, the order is spelled out in the code instead of inferred from field declaration position, which is a much better trade when the ordering constraint is actually load-bearing.
There's also ManuallyDrop<T>, which gets you the same explicit control without the Option tag. The cost is an unsafe block to call ManuallyDrop::drop yourself, since the compiler can no longer prove it happens exactly once. Reach for Option first; ManuallyDrop is for when the tag byte or the branch actually shows up in a profile.
For locals, just call drop
For local variables, the equivalent tool is just calling drop(x) directly, which moves x and drops it immediately rather than waiting for the end of scope.
This is the standard fix for the classic "why did this deadlock" bug, where a MutexGuard is still alive, and therefore still holding the lock, later in the same function than you intended:
let guard = mutex.lock().unwrap();
do_something_with(&guard);
drop(guard); // release the lock now, not at the end of the function
do_something_else_that_also_wants_the_lock();
Panicking destructors make ordering bugs worse
One more wrinkle worth flagging rather than exploring in depth: if a Drop impl panics while the thread is already unwinding from an earlier panic, Rust doesn't try to keep going, it aborts the process.
Ordering bugs and panicking destructors compound each other. So a Drop impl that can fail is worth writing defensively (log and continue, don't propagate) regardless of what order it runs in.
None of this makes Rust's RAII model unreliable. Deterministic destruction is still exactly what it promises, and it delivers on that promise. The catch is that "deterministic" and "the order you'd guess" are different claims, and the reference spells out which one you actually get.
Worth a five-minute check the next time a struct's fields include anything with a side-effecting Drop impl and a real dependency between them.