The Cycle That Never Frees: Rust's One Structural Leak
The shared-ownership story so far has a happy ending every time: counts fall to zero, destructors run, memory returns. Here is the case where that never happens — written in completely safe code:
use std::rc::Rc;
use std::cell::RefCell;
struct Link { peer: RefCell<Option<Rc<Link>>> }
let x = Rc::new(Link { peer: RefCell::new(None) });
let y = Rc::new(Link { peer: RefCell::new(None) });
*x.peer.borrow_mut() = Some(Rc::clone(&y)); // x -> y, strong edge
*y.peer.borrow_mut() = Some(Rc::clone(&x)); // y -> x, strong edge
assert_eq!(Rc::strong_count(&x), 2); // your handle + y's reference
drop(x);
drop(y); // every external handle is gone...
// ...and each node still holds the other: both strong counts sit at 1, forever
Neither count can reach zero because each node's count is fed by the other node, and the "other node" is only kept alive by it. No root handle remains, no destructor runs, no bytes return to the allocator — a structural leak: the graph owns itself.
Rust's stance on this is worth stating precisely for interviews. The language guarantee is memory safety, not absence of leaks: safe Rust is forbidden from freeing memory too early (use-after-free, double-free) and from racing on it — leaking is neither, so it is permitted (mem::forget and Box::leak are safe functions by design). What makes cycles different from those is intent: nobody decided to keep the bytes.
Where do cycles come from in real code? Any structure with a back edge: a doubly-linked list storing prev as Rc<Node> (each half keeps the other alive), a document tree whose children point up at parents, an event bus whose subscribers hold Rc back to the bus while the bus holds Rc to each subscriber, a cache whose entries reference the cache. In every case the forward edge is genuine ownership and the back edge is mere knowledge — manufacturing the loop is what happens when knowledge is stored as ownership.
This is the textbook limitation of reference counting, and it is worth knowing how the rest of the industry answers it. A tracing collector marks everything reachable from roots and sweeps the rest — cycles need no special treatment, because a cycle unreachable from every root is simply never marked. Plain reference counting never walks the graph, so it cannot distinguish "still in use" from "only keeping each other alive". Languages built on RC therefore bolt on a cycle collector (CPython's gc module periodically traces container objects and unlinks the cycles it finds); Rust's standard library ships none — the contract is that you keep the graph acyclic, which is precisely what Weak makes expressible.
Detection in life, from cheapest to most precise: Rc::strong_count above zero for any node after all external handles are dropped (assert this in tests — it is your leak oracle), process RSS climbing across a steady-state loop in a long-running service, and a heap profiler showing Rc allocations with no live incoming roots. The count is the cheapest because it is exact: it names which node survives, and the surviving node's owners are the edges to weaken.
Two leaks, two characters. mem::forget is a deliberate, greppable decision on one line; a cycle is diffuse — every edge looks correct on its own and only the aggregate refuses to die. That is why the discipline belongs in data-model review rather than in a linter: the moment two structures can reference each other, someone must decide which reference owns, and the other gets demoted to a watcher.
Breaking the Cycle: One Direction Owning, One Direction Watching
The cure is a design rule, not a library call: strong pointers flow one way; anything pointing back is Weak. Applied to the tree everyone writes first:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
value: i32,
children: RefCell<Vec<Rc<Node>>>, // parent -> children: OWNS (strong)
parent: RefCell<Weak<Node>>, // child -> parent: OBSERVES (weak)
}
let root = Rc::new(Node { value: 1, children: RefCell::new(Vec::new()), parent: RefCell::new(Weak::new()) });
let kid = Rc::new(Node { value: 2, children: RefCell::new(Vec::new()), parent: RefCell::new(Weak::new()) });
*kid.parent.borrow_mut() = Rc::downgrade(&root); // back edge: Weak, count untouched
root.children.borrow_mut().push(Rc::clone(&kid)); // forward edge: strong
let root_outside = Rc::downgrade(&root); // an external observer, too
assert_eq!(Rc::strong_count(&root), 1); // only your handle owns root
assert_eq!(Rc::weak_count(&root), 1); // the child only watches
drop(root); // teardown starts at the top
// root's value dies (strong hit 0) -> its Vec drops -> kid's strong count 2 -> 1
assert!(root_outside.upgrade().is_none()); // root is gone...
assert!(kid.parent.borrow().upgrade().is_none()); // ...so the kid's view of it, too
// kid itself survives (your handle) and the whole graph freed with no cycle
The counts show why it works: downgrade added zero to anyone's strong count, so ownership still forms a forest — edges all flow away from the root, and destroying the root cascades through the strong edges to every descendant. The weak back edges simply start answering upgrade() == None, which code that walks parent must handle anyway (Weak::upgrade returns Option by design).
Notice what the fix costs: one pointer's worth of indirection and a checked upgrade. A Weak is a pointer into the same allocation; downgrading and upgrading are a handful of instructions, there is no registry, no global table, no bookkeeping outside the block itself. Compare the alternatives you might reach for — an arena keyed by node id, a HashMap<usize, Weak<Node>> of "known" nodes — and the weak pointer wins by being the back-reference itself, stored inline with liveness semantics attached.
Detach-without-freeing is the same idea one step further: replacing a strong back-edge slot (*link.borrow_mut() = None, or a Weak::new() where the type is weak) drops exactly one strong count while your own handle keeps the node alive. That is how you take a node out of a shared structure without destroying it — the count, not the pointer, decides death.
When you inherit an already-cyclic structure — a doubly-linked list, mutual observers, a cache that points back at its owners — you have exactly two moves: choose the edge that is semantically weaker and store it as Weak, or break the cycle explicitly at teardown (*node.prev.borrow_mut() = Weak::new(); or clearing the Rc in the back edge). Weak::new() is the empty weak pointer: no allocation, upgrades to None, ideal for "detached" state. There is also construction-time help: Rc::new_cyclic (stable since Rust 1.60) gives your constructor a Weak to its own not-yet-built value, so self-referential nodes can be created without ever storing a strong self-loop.
Interview framing: "can safe Rust leak memory?" is the perfect curveball, and cycles are the answer it is fishing for: yes — the guarantee is safety, not collection — and the discipline is one-directional ownership with Weak back-pointers. Be ready to draw the parent/child counts; they make the argument concrete. The mutation needed to build these graphs came from Cell, RefCell, and Interior Mutability.
Proving Nothing Leaks: The Standard Library's Drop-Count Technique
Claims about freeing are cheap; tests prove them. The standard library's own Rc tests (library/alloc/src/rc/tests.rs) do not guess whether graphs get collected — they count destructors. The technique reproduces in any codebase:
use std::cell::Cell;
use std::rc::Rc;
thread_local! {
static DROPPED: Cell<usize> = const { Cell::new(0) };
}
struct Tracked(String);
impl Drop for Tracked {
fn drop(&mut self) { DROPPED.with(|d| d.set(d.get() + 1)); }
}
fn dropped() -> usize { DROPPED.with(|d| d.get()) }
Build the shapes that matter, then assert after dropping the roots:
// diamond DAG: two parents sharing one child (not a cycle — must still free)
let child = Rc::new(Tracked(String::from("shared")));
let left = Rc::new(vec![Rc::clone(&child)]);
let right = Rc::new(vec![Rc::clone(&child)]);
assert_eq!(Rc::strong_count(&child), 3);
drop((left, right, child));
assert_eq!(dropped(), 1); // one Tracked, dropped exactly once
// cycle: must NOT free — and the test documents that
let before = dropped(); // counter is cumulative on this thread
let a = Rc::new(Tracked(String::from("a")));
let b = Rc::new(Tracked(String::from("b")));
// ... link a <-> b through RefCell<Option<Rc<..>>> as before ...
drop((a, b));
assert_eq!(dropped(), before); // leaked by cycle — this is the behavior under test
Three properties the std suite pins down, and you should pin down too: no double drop (every Tracked destructor runs at most once), no premature drop (counts stay correct while strong references exist — a diamond child drops once, not three times), and eventual drop (acyclic graphs free completely when roots go). Together they are the operational meaning of "reference counting collects correctly": the count is a precise oracle of liveness whenever the graph is acyclic.
The technique ports directly out of the test suite. Wrap a domain object in a drop-counting newtype (or gate a tracing::debug! on its Drop), run your real teardown path in an integration test, and assert the count: a leak then fails the build naming the object, long before anyone opens a profiler. For Arc graphs in threaded code the counter simply becomes an AtomicUsize — drop the roots on every thread, join them, assert — and Rc::into_raw/from_raw round-trips deserve a case of their own, because a forgotten raw pointer is a leak the counts will show and the compiler will not.
Why does the std test suite bother? Because Rc's correctness is its counting logic — increment on clone, decrement on drop, strong-zero destroys the value, weak-zero frees the block — and those paths interact with mem::forget, Rc::into_raw/from_raw, try_unwrap, and make_mut (which dissociates weak pointers). The tests keep the interactions honest. Your version adds the debugging reflex: when memory climbs, assert Rc::strong_count/weak_count at teardown and find the edge you forgot to weaken — the count tells you which node is still owned, and from there the owner is usually one line away.
Interview framing: "how would you test that something doesn't leak?" is asked more often than people expect. Answer with the instrumented-Drop counter, name the three assertions (once / not-prematurely / completely), and mention that std does exactly this for Rc. Then close the loop: cycles are why Weak exists, and Spawning Threads is where the roadmap leaves single-threaded ownership behind.
Quiz
1. Two Rc values point at each other and all external handles are dropped. What happens to the allocations?
2. A tree stores children as `Vec<Rc<Node>>` and the parent as `Rc<Node>` in each child. Why does this leak, and what is the fix?
3. What does `weak.upgrade()` return after the value's last strong owner is dropped, and why is it an Option?
4. Which statement about leaks in Rust is correct?
Flashcards
Question
Why does an Rc cycle leak, and what does the count show?
Click to reveal answer
Answer
Each node's strong count is incremented by the other node's reference, so with external handles gone both counts stay ≥1; strong-zero — the only free trigger — never fires, so no destructor runs. `Rc::strong_count` staying above zero after dropping every root is the diagnostic signature of the cycle.
Question
What is the rule for avoiding Rc cycles?
Click to reveal answer
Answer
Strong pointers flow one way: parents/owners hold `Rc`, back-pointers/observers hold `Weak`. `Rc::downgrade` creates the back edge without touching the strong count, and upward walks use `weak.upgrade()` (an Option) — teardown then cascades down the owning edges and nothing outlives its owner.
Question
How do you break an already-built cycle?
Click to reveal answer
Answer
Weaken or clear one edge: `*node.back.borrow_mut() = Weak::new();` (empty weak, upgrades to None) or set the back `Rc` slot to None so the strong count decrements. Choose the semantically weaker edge — the one that only observes. For self-reference at construction, `Rc::new_cyclic` hands the constructor a Weak before the value exists.
Question
What do std's Rc leak tests actually assert?
Click to reveal answer
Answer
Destructor counts via a Drop-tracking payload: every tracked value is dropped **exactly once** (no double drop), **not while strong references exist** (no premature drop), and **completely when roots are dropped** for acyclic graphs (diamonds/DAGs share children without leaking). A cycle test asserts the opposite — count stays zero-dropped — documenting the known boundary.
Question
Strong vs weak count, restated as teardown rules.
Click to reveal answer
Answer
strong == 0 → the value is destroyed (destructors run); the block is reclaimed only when weak also hits 0. Weak handles never prevent value destruction and never dangle: upgrade() reports None after strong is gone. Any edge that would feed a count while pointing backwards should be stored as Weak.
Revision Notes
Key Takeaways
- 1. A cycle leaks because each node's strong count is fed by the other: with no external handle, neither count can reach zero, so destructors never run — Rust's guarantee is safety, not collection
- 2. The prevention rule is one-directional ownership: parents hold Rc, back-pointers hold Weak, and every upward walk handles upgrade() returning None
- 3. Cycles you inherit are broken by weakening or clearing the semantically weaker edge — Weak::new() detaches, Rc::new_cyclic builds self-reference without a strong self-loop
- 4. strong == 0 destroys the value; weak == 0 (also zero strong) frees the block — which is why Weak observers can outlive the value safely and answer None
- 5. std proves its own counting with instrumented Drop tests: exactly once, never premature, complete for acyclic graphs — the same assertions to copy when auditing your own shared structures
Interview Tips
- • "Can safe Rust leak memory?" — yes: mem::forget and Box::leak are safe by design, and Rc cycles leak accidentally; the guarantee is no use-after-free or races, not collection — then show you know the fix (Weak back-pointers)
- • "Draw a data structure that leaks with Rc" — parent/child tree or doubly-linked list with strong pointers both ways; show the counts after dropping the root, then flip the back edge to Weak and show teardown cascading
- • "How do you detect a leak in production?" — strong_count above zero after teardown, RSS growth, heap profiles showing rootless Rc allocations; then assert counts at drop points to find the owning edge
- • "How would you test Rc collection?" — instrumented Drop counter with the three assertions (exactly once, never early, complete for acyclic), note std does this in library/alloc/src/rc/tests.rs, and mention cycle tests document the boundary
Cheat Sheet
Reference Cycles and Weak
The leak: strong edges in both directions → both counts stay ≥ 1 with no root handle → strong-zero never fires → value never dropped, block never freed.
let x = Rc::new(Link { .. }); let y = Rc::new(Link { .. });
*x.peer.borrow_mut() = Some(Rc::clone(&y)); // strong, both ways = cycle
*y.peer.borrow_mut() = Some(Rc::clone(&x));
assert_eq!(Rc::strong_count(&x), 2);
drop((x, y)); // counts still 1: LEAKED
The rule: owning edges are Rc, back-pointers are Weak.
| Operation | Count effect | Result |
|---|---|---|
Rc::clone |
strong +1 | value lives longer |
Rc::downgrade |
weak +1 | lifetime unchanged |
weak.upgrade() |
temporary strong +1 | Some(Rc<T>) / None if already gone |
| strong hits 0 | value destroyed | remaining Weak answers None |
| weak also hits 0 | block freed |
Fixes:
parent: RefCell<Weak<Node>> // store the back edge weak from the start
*node.back.borrow_mut() = Weak::new(); // detach an existing cycle
Rc::new_cyclic(|me| Node { me: me.clone(), .. }) // self-reference at construction (1.60)
Leak testing (same technique std uses): instrument Drop with a counter → build graph (tree / diamond / cycle) → drop roots → assert: dropped exactly once, not early, and completely (acyclic); strong_count > 0 after teardown flags the forgotten edge.
Guarantee: Rust promises memory safety, not collection — mem::forget / Box::leak are safe; cycles leak structurally; nothing about leaking is unsound.