Skip to content

← Unsafe and Soundness step 21 of 24

Hard Framework

Leaking is safe, and exception safety

pub struct Drain<'a, T> { /* yours */ }

pub fn drain_script(ops: Vec<String>) -> Vec<String>

The starter hands you a MyVec<T> with push, pop and Deref. Write a Drain<'_, T> guard for it — an iterator that empties the vector, yields its elements by value, and drops whatever the caller did not take.

op emits
push S "ok"
drain_take N drains, yields the first N elements joined by |, then drops the drainer
drain_forget N drains, yields the first N, then mem::forgets the drainer
len the length
join the elements joined by ,

The starter’s Drain repairs the vector in its destructor, which is the natural first design and is unsound. The drain_forget cases fail, and they fail in a way you can read.

The rule

Unsafe code may not assume destructors run.

mem::forget is a safe function. So is Box::leak. So is building an Rc cycle. So is ManuallyDrop. Rust makes leaking safe on purpose — a leak wastes memory but cannot corrupt it — and the consequence for anyone writing unsafe code is absolute:

Any API whose soundness depends on a destructor executing is unsound. Full stop.

Not “risky”. Not “please don’t”. Unsound, in the same sense as a dangling pointer, because a safe caller can reach the bad state with no unsafe block anywhere.

::: question mem::forget is safe, but it can obviously break invariants. Why did the language not make it unsafe? Because it cannot be prevented, so pretending otherwise would be worse.

Even if mem::forget were removed tomorrow, Rc::new(cell) where the cell points back at the Rc leaks with no unsafe and no forget. So does Box::leak. So does a loop {} inside a destructor. So does aborting the process. Leaking is reachable from safe code by construction, and a guarantee the language cannot enforce is not a guarantee.

Rust’s response was to draw the line in the honest place: memory safety is guaranteed; memory reclamation is not. That is why Drop is a best-effort convenience rather than a linear-type obligation, and why every unsafe abstraction has to be written to survive its own destructors never running.

The history is worth knowing. Pre-1.0, thread::scoped returned a JoinGuard whose destructor joined the thread — so borrowing a stack local into the thread was “safe”, provided the guard was dropped. mem::forget the guard and the thread kept running with a reference to a dead stack frame. The API was pulled days before 1.0. The episode is remembered as the leakpocalypse, and it is exactly why today’s thread::scope takes a closure: if the API owns the scope, forgetting is impossible. :::

Leak amplification

Here is the technique, and it is one line.

A naive Drain records the vector’s length, starts moving elements out, and repairs len in its own destructor. Between construction and destruction the vector is inconsistent: len claims elements that have been moved out and dropped. Forget the drainer and that inconsistency becomes permanent — a Vec whose len describes freed values. Reading it is a use-after-free from safe code.

Leak amplification inverts it. Set len = 0 at construction, before a single element moves:

pub fn drain(&mut self) -> Drain<'_, T> {
    let start = self.ptr.as_ptr();
    // SAFETY: len <= cap
    let end = unsafe { start.add(self.len) };
    self.len = 0;              // <- the entire technique
    Drain { start, end, _marker: PhantomData }
}

Now at every instant len describes exactly the slots the vector still owns: none. Forget the drainer and the un-yielded elements leak — the vector is consistent but lossy. Push into it afterwards and everything works normally, because the buffer was never freed and len was never a lie.

Leak more rather than corrupt. That is the slogan. Leaking is an acceptable outcome; undefined behaviour is not.

::: question After drain_forget 1 on a three-element vector, what should len and join report — and what does the naive version report? Correct: len is 0 and join is the empty string. The two unyielded strings have leaked, and the vector is a perfectly ordinary empty vector that you can push into again.

Naive: len is 3, because the repair lived in a destructor that never ran. join then builds a slice over three slots, one of which was ptr::read out and dropped — so it formats a String whose heap buffer has been freed. On a good day that is garbage; on a bad day it is a segfault; on the worst day it is plausible-looking output.

This is the rare problem where the harness can observe the fix, because leak amplification has a visible behavioural signature: the len assertion fails deterministically. Most soundness fixes in this track have no signature at all. :::

Exception safety, the same rule wearing a different hat

A panic can unwind out of a user closure, a Clone impl, an allocation failure path, or arithmetic overflow in a debug build. If it unwinds while your structure is temporarily violating its own invariant, the destructors that run during unwinding will observe the broken state.

Two disciplines cover almost everything:

  • update the length last — never publish slots before they hold values;
  • use a drop guard whose own Drop repairs the invariant, for the cases where “last” is not achievable.

Distinguish the two levels. Basic exception safety — no UB, no leak of the container itself, the structure still valid — is what unsafe code must guarantee. Strong exception safety — all-or-nothing, the operation either completes or leaves everything untouched — is a design nicety you provide when it is cheap.

Two traps. A drop guard whose own Drop can panic aborts the process (panic during unwind is a double panic). And you may not assume panic = "abort" — that is a binary-level choice, and a library cannot make it. Related: unwinding across an extern "C" boundary is undefined behaviour, which is why extern "C-unwind" exists.

Lints, and one that will not fire

clippy::mem_forget is restriction/allow. It will not fire, here or anywhere, unless somebody switches it on. Say that out loud, because learners assume clippy polices mem::forget and it does not — the whole point of this page is that mem::forget is a normal, legitimate, safe function.

What is on: clippy::forget_non_drop (forgetting a type with no destructor is pointless) and clippy::undropped_manually_drops (deny — drop(manually_drop_value) does nothing at all, because ManuallyDrop has no Drop to run; you need ManuallyDrop::drop).

What this grader cannot check

The leak itself. After drain_forget 3, three Strings are gone forever and nothing here can see it — which is precisely the point: that leak is the correct behaviour. What is checked is that the vector survives it intact.

Remember the grade is compile + tests + clippy -D warnings.

Loading visualization…