Rust — Rc Reference Cycles Leak Memory
Diagnose memory growth in Rust from Rc and RefCell cycles that the ownership model cannot free, break cycles with Weak, and verify with leak tests.
Symptoms
- RSS grows continuously in a long-running process while valgrind-like tools show no traditional leak
- Dropping the top-level object does not release its graph: nested nodes persist in heap profiles
- Structures with parent and child links (trees, caches, observers) grow unboundedly under workload
Possible causes
- Parent holds Rc<Child> and child holds Rc<Parent>, so strong counts never reach zero
- An observer stored in a registry holds Rc back to the registry
- RefCell interior mutability links nodes in both directions after construction
- Caches keyed by objects that also reference the cache
Diagnosis steps
Confirm the retention with a heap profile
cargo run --release -- --heap-profile 2>&1 | head; or use a dhat-based buildInterpretation
A stable population of the node type after dropping the root shows retention, not allocation churn — the fingerprint of a cycle.
Map the strong-reference edges
grep -Rn "Rc<\|Rc::clone\|upgrade()" src/ | head -20Interpretation
Each Rc field is an edge; edges forming a closed loop among types are the leak topology.
Break the back-edge and verify
cargo test --release leak_checkInterpretation
Replacing one direction with Weak and asserting drop runs (via Drop impl counting) proves the cycle was the cause.
Root cause
Rc frees memory when the strong count reaches zero. A cycle keeps every member count at one or more even after all external references are dropped, so the ownership model cannot reclaim it: the memory is reachable from itself and no destructor ever runs.
Fix
Designate one direction as weak: children hold Weak<Parent> (or observers hold Weak<Registry>), upgrade() at use time with graceful absence handling, and document the ownership direction. For cache-like graphs, Weak values let entries evaporate when the key object dies.
Risk note
upgrade() returning None is a new failure mode at every call site. Handle it deliberately rather than unwrapping.
Verify the fix
A drop-counting test shows node counts returning to zero after workload; long-running soak tests show RSS plateau instead of growth.
Symptom walkthrough
The leak hides from intuition because each Rc is individually correct:
struct Node {
parent: RefCell<Option<Rc<Node>>>, // strong back-edge
children: RefCell<Vec<Rc<Node>>>,
}
Drop the root and nothing is freed: the root’s children each hold a strong count on it, and it holds strong counts on them. The heap profile shows a stable population of Node allocations after the root is gone — retention without allocation churn, which is the cycle fingerprint. Traditional leak tools stay quiet because the memory is still “reachable”: reachable from itself.
Fix in practice
Pick the ownership direction and demote the back-edge:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
struct Node {
parent: RefCell<Option<Weak<Node>>>, // weak: no cycle
children: RefCell<Vec<Rc<Node>>>, // strong: parent owns children
}
impl Node {
fn parent(&self) -> Option<Rc<Node>> {
self.parent.borrow().as_ref().and_then(|w| w.upgrade())
}
}
The rule of thumb: ownership flows downward (container to contained), navigation flows upward weakly. Caches invert it — strong keys outside, Weak values inside — so entries vanish when the referent dies:
let cache: HashMap<Id, Weak<Asset>> = HashMap::new();
And prove it with a destructor that counts:
impl Drop for Node {
fn drop(&mut self) { DROPPED.fetch_add(1, Ordering::Relaxed); }
}