Skip to content

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.

A two-panel memory aid. The first traces scope entry, resource acquisition, early return or panic, Drop, field declaration-order destruction, reverse local destruction, and warns that process abort does not guarantee destructors. The second contrasts a strong Rc cycle, which keeps counts above zero, with a Weak back edge that does not own.
RAII gives deterministic cleanup along Rust control flow, not a guarantee that every process exit completes a protocol. Strong reference cycles retain owners; Weak preserves navigation without ownership.

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

  1. RAII connects ownership to deterministic cleanup on ordinary Rust control flow.
  2. Local and field destruction orders differ and can affect resource dependencies.
  3. Drop is not a reliable channel for fallible, blocking, or distributed completion.
  4. Safe code may leak; soundness cannot rely on destructor execution.
  5. 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