The Rust Engineering Handbook / Chapter 11
RAII, Drop, Destruction Order, Leaks, and Cycles
Design resource owners whose cleanup, shutdown, panic, and leak behavior is explicit.
Four exits from one spool rotation
A relay is rotating its spool. It has opened a temporary file, wrapped it in a buffered writer, and registered the in-progress rotation with local metrics. A successful rotation must flush the buffer, synchronize the file according to the service’s durability policy, rename it into place, and report that result.
Now give the same owner four exits. On success, every protocol step can report
failure. If a write returns early with ?, Rust drops the initialized writer
and registration while the old spool remains authoritative. If the thread
panics and unwinds, those live owners are dropped too, but no caller receives a
cleanup result. If the process aborts or is forcibly terminated, Rust promises
no destructor run at all; restart recovery must judge the durable files it
finds.
“Drop cleans it up” loses the important differences among those exits. Rust
gives deterministic destruction at semantic points in ordinary control flow
and during unwinding. It does not turn destruction into a successful storage,
network, or shutdown protocol.
That leaves a useful division of labor. A destructor should perform bounded, infallible local cleanup. Work that can block, fail, coordinate several owners, or establish durability belongs in an explicit operation whose result the caller can observe.
RAII ties validity to ownership
Resource Acquisition Is Initialization means a value becomes usable only after
construction establishes its invariant, and ownership of that value carries
cleanup responsibility. File, MutexGuard, temporary directories,
transactions, and registration guards use the same shape: establish valid
state, expose operations while it remains valid, then release or roll back when
the owner is dropped.
The spool owner can therefore hold the temporary file and metrics registration
only after both have been created successfully. If construction fails halfway
through, Rust drops the fields and locals that were initialized; there is no
half-valid owner for callers to repair. Later, ?, return, and unwinding all
leave scopes through the same ownership machinery.
RAII is stronger than remembering a cleanup call on every branch and weaker
than a successful shutdown protocol. Drop::drop cannot return an error, may
never run after abort, and should not wait indefinitely for external
cooperation.
Drop order is an observable semantic rule
Local variables are dropped in reverse declaration order. Struct fields are dropped in declaration order after the type’s Drop::drop method returns. Tuple and array elements follow their documented order. Temporaries have drop scopes determined by their enclosing expression and statement; do not infer them from visual indentation alone.
let journal = Resource::open("journal")?;
let index = Resource::open("index")?;
// index drops before journal on ordinary scope exit.
Declaration order can therefore encode dependency order, but a dependency
hidden in field position is fragile. If index cleanup must use journal, put
the relationship behind one owner or call an explicit close sequence. The
executable ch11_drop example records local destruction order so a change in
declarations cannot silently reverse the intended sequence.
std::mem::drop(value) is an ordinary function that consumes its argument. It is useful for ending a guard before a callback, releasing a lock before expensive work, or making resource timing visible. It does not invoke a special language escape hatch. drop(&value) only drops the reference value and does not end ownership of the referent.
Figure 11-1 traces the paths that do reach destruction and keeps abort outside that promise.
Drop flags protect partially moved and initialized state
The compiler tracks which values and move paths are initialized so it drops only live values. This supports conditional initialization, partial moves where permitted, and unwinding through partially completed construction. “Every field drops” is therefore imprecise: every still-initialized field that the active control path owns drops according to its drop scope.
This becomes visible when SpoolRotation::finish(self) needs to take its writer
and finish it before renaming the file. A type implementing Drop cannot move
an individual field out through ordinary destructuring because its destructor
is entitled to observe the complete value. Storing the writer in an Option
and calling take lets finish move it out while leaving a valid None for
the fallback destructor to see. Replacement or a consuming method can serve
the same invariant. Low-level partial initialization belongs behind carefully
reviewed abstractions; it is not a reason to reach for ManuallyDrop in
routine domain code.
Destructors cannot report protocol failure
Drop::drop returns (). A destructor may release memory, close an in-process handle, decrement a count, or attempt best-effort cleanup. It cannot make a failed flush or remote acknowledgement visible through its signature.
A resource that needs reliable completion should expose finish, commit,
shutdown, or close returning Result. For the spool rotation, a consuming
finish(self) can flush and synchronize the writer, install the new spool, and
return the first failed operation. Because it consumes the owner, successful
completion cannot be called twice. If retry is part of the protocol instead,
the state machine must retain enough information to distinguish safe retry
from repeating an irreversible step.
The destructor has a smaller job: release in-process handles and make a bounded attempt to remove an abandoned temporary file. A failed removal may be recorded only through a channel that is itself safe during teardown; durable recovery must still tolerate the file on restart. This fallback improves hygiene without pretending to prove that rotation completed.
Panicking inside Drop is dangerous. If a destructor panics while another
panic is already unwinding, the process normally aborts. Even without a double
panic, callers cannot recover through the destructor API. Treat destructor
panics as invariant failures to eliminate, not a validation channel. Network
I/O, an unbounded worker join, or remote consensus also has no place there:
each hides latency in scope exit and can deadlock during shutdown. The spool’s
background uploader needs an explicit, bounded shutdown and acknowledgement;
dropping its last handle is not such a protocol.
Leaks are memory-safe but operationally significant
Rust’s safety contract does not promise every allocation will be reclaimed. mem::forget consumes a value without running its destructor and is safe because programs can already leak through cycles or process lifetime. Box::leak deliberately converts owned allocation into a reference that may live for the remainder of the process.
Safe does not mean desirable. Leaking a heap buffer spends memory; leaking a
file or guard can exhaust descriptors or retain synchronization state. More
fundamentally, safe code may call mem::forget. An abstraction whose memory
safety depends on its destructor restoring an invariant is therefore unsound.
The spool may leave a temporary file or registration behind when forgotten,
but it must not leave memory that safe code can access in an invalid state.
Intentional process-lifetime data can be reasonable for immutable tables or one-time initialization. Record the bound, explain why reclamation is unnecessary, and distinguish it from accidental growth.
Strong cycles retain ownership
Two Rc values that strongly own each other never reach a strong count of
zero. The same ownership problem exists with Arc; atomic counting changes
thread capability and cost, not graph topology. If the spool owns an uploader
handle and the uploader’s registry strongly owns the spool, neither destructor
runs after external owners disappear.
Use Weak for parent links, caches, registries, observers, and other
relationships that may navigate to an owner but must not keep it alive. In the
spool design, the supervisor can own both components while the uploader keeps
only a weak route back to status. That makes the supervisor—not a reference
count accident—responsible for shutdown order.
Upgrading Weak returns Option because the owner may be gone. That absence is part of the design, not a nuisance to unwrap. If both nodes truly require each other to exist, a higher-level owner can store both and connect them by IDs or borrowed access instead of creating mutual ownership.
Choose from the obligation, not the convenience
Use Drop when releasing a local resource is bounded and cannot report a
meaningful error. Use a consuming finish(self) when completion can fail and
abandonment needs a separate fallback. End a guard with a smaller scope—or
with drop(guard) when the boundary deserves emphasis—before calling code that
must not run under that guard.
In an ownership graph, use Weak<T> for navigation that must not extend the
owner’s lifetime, and accept that upgrade can fail. A deliberate leak fits only
when process-lifetime storage is genuinely the contract and its resource bound
is explicit. These designs do not rank from primitive to sophisticated; each
answers a different question about who observes failure and who controls the
lifetime.
Failure modes
- Assuming destructors run after abort,
_exit, power loss, or forced termination. - Performing unbounded blocking or fallible durability work only in
Drop. - Panicking on cleanup errors, especially during unwinding.
- Relying on reference counts to collect a cyclic graph.
- Treating
Weak::upgrade()failure as impossible. - Claiming a resource is sound only because its destructor restores an invariant.
- Calling
drop(&guard)when the owned guard must be released.
Senior review checklist
- Which owner releases each resource, and on which control-flow paths?
- Which work is infallible local cleanup, and which requires an explicit result?
- Is declaration/drop order a real dependency, and is it tested?
- Can a destructor block, call foreign code, reenter, or panic?
- Does the design remain memory-safe if the value is forgotten?
- Can strong pointers form a cycle; which edges are observational?
- What happens on abort or forced termination?
- Is explicit shutdown bounded, idempotent, and observable?
Audit exercise: a spool owner under failure
Audit a type owning a temporary file, buffered writer, metrics registration, and background uploader. Produce a destruction timeline for successful commit, validation error, panic, dropped future, and process abort. Separate operations suitable for Drop from a consuming finish. Specify field order, double-panic behavior, retry/idempotency, the bound on destructor work, and tests that record cleanup order. If the uploader and spool retain each other, replace one strong edge with Weak, an ID, or a higher-level owner and defend the choice.
Durable takeaways
- RAII connects ownership to deterministic cleanup on ordinary Rust control flow.
- Local and field destruction orders differ and can affect resource dependencies.
Dropis not a reliable channel for fallible, blocking, or distributed completion.- Safe code may leak; soundness cannot rely on destructor execution.
- Reference counting does not collect cycles; ownership topology must be designed.
The ownership model is now complete at the level of a value: acquisition, access, mutation, and destruction all have named responsibilities. The remaining difficulty is architectural. When borrow friction spreads across a service, the next useful move is not another local wrapper but a map of lifecycles, identities, and transfer boundaries.
Sources and version notes
- Rust Reference: destructors
std::ops::Drop,std::mem::drop, andstd::mem::forgetstd::rc::Weakandstd::sync::Weak- Examples verified on Rust 1.97.0 and the declared Rust 1.85.0 MSRV on Linux. Process termination behavior depends on termination mode and platform; no claim is made that destructors run at process exit.
Continue reading
Full table of contents