Skip to content

← Ownership I: Moves, Copy, Clone, Drop step 19 of 20

Hard Primitives

Leaking is safe

Run a script of four commands and produce a log in which most of the destructors never run.

pub fn leak_report(script: Vec<String>) -> Vec<String>

The correction

Everything you have internalised in this track has an expert-tier asterisk on it, and this is where it goes:

Destructors are not guaranteed to run.

Not “usually run”. Not “run unless you do something unsafe”. std::mem::forget is a safe function — no unsafe block, no feature flag — with the signature

pub fn forget<T>(t: T)

and the documented behaviour of taking ownership and then never dropping the value. Rc cycles leak by construction. ManuallyDrop<T> is a safe wrapper whose destructor does nothing. And std::process::exit terminates without unwinding anything at all.

Why Rust made this safe

mem::forget was unsafe before Rust 1.0 and was changed deliberately. The reasoning is worth understanding because it is a rare case of a language conceding a point:

Leaks cannot be prevented in safe code anyway. Two Rcs pointing at each other leak, using nothing but safe std. Marking forget unsafe would have made unsafe mean “may leak”, which would have been a lie in both directions — safe code leaks, and most unsafe code does not.

Therefore unsafe code may never rely on a destructor for soundness. This is the real consequence and it is a load-bearing rule of the entire ecosystem. If your unsafe block is correct only because some guard’s destructor will run, your code is unsound, because a caller can mem::forget the guard using nothing but safe code.

The Rustonomicon documents three case studies of exactly this, and they are worth knowing by name:

  • Vec::drain and leak amplification. Drain moves elements out of a vector while the vector still exists. If its destructor is skipped, the vector must not be left claiming to own elements that have been moved out — that would be a genuine use-after-free the next time anything touched it. std’s answer is to set the vector’s length to zero up front, and restore the true length in Drain‘s destructor. Forget the Drain and you get an empty vector: everything still in it is leaked, but nothing is invalid. Deliberately leaking more than was leaked, in order to stay consistent, is called leak amplification, and it is the standard technique for making a type sound in the face of mem::forget.
  • Rc refcount overflow. Rc::clone increments a usize. Forget enough clones and the count could wrap to zero, freeing memory other handles still point at. std’s answer is to abort the process on overflow rather than risk a use-after-free — a rare place where the standard library chooses “kill everything” over “be wrong”.
  • thread::scoped, removed in 2015. It let a thread borrow stack data, relying on a join-on-drop guard to guarantee the thread finished before the frame died. mem::forget the guard and the thread outlives the borrowed stack. The API was removed in Rust 1.0’s run-up; the modern thread::scope (stable since 1.63) has a closure-based design where the join point cannot be skipped.

The takeaway, in one line: safe code may assume destructors run; unsafe code may not.

::: question If a destructor is not guaranteed, how is RAII trustworthy at all? Because the guarantee that matters for RAII is weaker than “always runs” and still strong enough.

For safe code, a value that goes out of scope normally is dropped. There is no path in safe Rust that skips a destructor except one you asked for by name: calling forget, wrapping in ManuallyDrop, building a cycle, or exiting the process. A MutexGuard in ordinary safe code will release the lock, and you may rely on that when reasoning about correctness — deadlocks, not use-after-free.

For unsafe code the bar is different, because unsafe code must be sound in the presence of any safe caller, including a hostile one. So the rule becomes: a destructor may make things nicer, but it may never be the thing standing between your program and undefined behaviour.

And note what leaking is not: leaking memory is not undefined behaviour. It is a bug, sometimes a serious one, but it never invalidates a pointer or corrupts memory. That is why Rust could afford to concede the point. :::

What to build

The grader injects the usual scaffolding — tracked(name, log) logging "new <name>", a Drop impl logging "drop <name>", note(log, line), new_log(), snapshot(log) — plus one extra:

fn make_cycle(log: Log)

which builds two Rc nodes pointing at each other, each owning a Tracked named cyc0 and cyc1, and lets both local handles fall out of scope. Neither refcount reaches zero, so neither destructor is ever reached.

Walk the script and handle four commands:

Command Do
normal N build a value named N and release it the ordinary way
forget N build a value named N and std::mem::forget it
manual N build a value named N and wrap it in std::mem::ManuallyDrop::new
cycle call make_cycle

Anything else: emit format!("? {cmd}").

For ["normal a", "forget b", "manual c", "normal d"] the log is

new a, drop a, new b, new c, new d, drop d

The assertion is the absence of the drop lines, which is exactly the lesson. Nothing panics, nothing is unsafe, and three of those four values are simply never destroyed.

Two traps

mem::forget(t.name) does not work. Reaching into a Drop type to pull one field out is E0509 — the same wall as earlier in this track. forget takes the whole value or nothing.

Clippy will not stop you. clippy::mem_forget exists, and it is in the restriction group, which means it is off by default and stays off unless a project opts in. Learners routinely assume the linter polices this; it does not. The lints that are on by default here are forget_non_drop and rustc’s forgetting_copy_types, and both fire on the useless cases — forgetting a value that has no destructor, or forgetting a Copy type (which leaves the original sitting right there). Neither has anything to say about the real thing.

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

Loading visualization…