Skip to content

← The Expert Edge: Idiom, Review and Capstones step 4 of 14

Hard End-to-End

Build Rc<T> and RefCell<T> from scratch

Implement MyRc<T> and MyRefCell<T> yourself, then drive both through a scripted workload and return the merged event log.

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

You have built each of these separately. Here they are combined, over the same tracked payload, with one log recording reference counts, borrow outcomes and drop events — because the interesting failures only show up when the two are composed. MyRc<MyRefCell<Node>> is the single most common shared-mutable shape in Rust, and it is also the shape that leaks.

The scaffold — the world map, the command dispatcher, Node, emit — is given and must not be changed. Everything above the the scaffold banner is yours: every body is todo!().

What you must build

struct RcBox<T> { strong: Cell<usize>, value: T }
pub struct MyRc<T> { ptr: NonNull<RcBox<T>>, _owns: PhantomData<RcBox<T>> }

MyRc::new, inner, strong_count, try_clone (returning None on overflow), the unsafe fn set_strong test seam, plus Clone, Deref and Drop.

pub struct MyRefCell<T> { state: Cell<isize>, value: UnsafeCell<T> }

state is 0 when free, n > 0 for that many shared borrows, -1 for one exclusive borrow. try_borrow, try_borrow_mut, try_swap, plus Deref / DerefMut / Drop on both guard types.

The four things the script is designed to catch

1. The self-swap trap. swap a a asks a cell to exchange contents with itself. Your try_swap takes two &Self, and nothing in the type system says they are different cells. If you take one exclusive borrow and then the other, the second must fail — which is exactly what std’s RefCell::swap does (it panics). If your implementation reaches in through UnsafeCell::get twice, you have created two &mut to the same place, which is undefined behaviour, and the script will show swap a a ok where it should show busy.

2. Refcount overflow. Rc cannot let its count wrap, because a wrap to zero frees memory that live handles still point at — a use-after-free reachable from entirely safe code. Real Rc handles this with a saturating abort; you handle it with checked_add and a None. The poison command uses the unsafe fn set_strong seam to slam the count to usize::MAX so the guard is reachable in a test rather than after 2⁶⁴ clones.

::: question set_strong only writes a number. Why is it unsafe? Because the number is the safety invariant. MyRc‘s Drop frees the allocation when the count reaches zero, and it is correct only if the count equals the number of live handles. A safe function that lets a caller write any value at all lets safe code cause a use-after-free — and “safe code cannot cause undefined behaviour” is the entire contract of the language.

This is the general rule for unsafe abstractions: the unsafe keyword belongs on every operation that can break the private invariant, not only on the ones that dereference a pointer. Raising the count, as poison does, is actually fine — it only leaks. Lowering it is a catastrophe. One function can do both, so it is unsafe, and its # Safety section says which direction is dangerous. :::

3. Guard-drop ordering. probe takes a shared borrow, tries an exclusive one while it is held (must fail), drops both, and tries again (must succeed). Run it twice in a row and it must give the same answer both times. That catches the classic bug: a guard whose Drop forgets to restore the count, or restores it to the wrong value, leaving the cell permanently poisoned. Notice what is doing the work here — the borrow counter is only correct because Drop runs, which makes MyRef/MyRefMut a textbook case of a type whose soundness depends on its destructor.

4. The cycle that must leak. link a b then link b a builds two nodes that hold MyRc handles to each other. Releasing every external handle leaves both counts at 1, so nothing is freed and neither Node::drop runs. The expected log for that case contains no drop lines at all, and if yours does, your Drop is freeing memory that is still referenced.

This is not a bug in your implementation or in std’s. Rc is a reference count, and reference counting cannot collect cycles — that is a documented, permanent property, and the standard answer is to make one direction of the cycle a Weak. Leaking memory is safe in Rust; mem::forget is a safe function for exactly this reason. Your MyRc is required to leak here in precisely the way std’s does.

The honest limit of this grade

There is no Miri in this harness. A sloppy implementation with real undefined behaviour can pass every case below, because UB is not obliged to misbehave and -O may compile an aliasing violation into code that happens to do what you meant. The cases have been chosen so that the specific mistakes people actually make — the double &mut in swap, the guard that does not restore its counter, the count that wraps — produce a different observable log. That is a real net, but it is a net with holes.

So write it as though something is watching: one // SAFETY: comment per unsafe block, saying which invariant makes that operation sound, and a # Safety section on every unsafe fn. If you cannot write the sentence, you do not have the proof, and passing the tests did not give you one.

Two clippy lints in this area are worth knowing even though this design avoids them. mut_from_ref is deny-by-default and fires on any safe fn f(&self) -> &mut T — the whole reason borrow_mut returns a guard rather than a reference. non_canonical_clone_impl catches a Clone that does something other than what Clone promises.

The scripting language

new <name> <payload>, clone <name>, release <name>, count <name>, read <name>, write <name> <text>, probe <name>, swap <a> <b>, link <a> <b>, poison <name>. Anything else logs bad <line>. After the last command the log gets a teardown line and the world is dropped in key order, so the final drops are deterministic.

Loading visualization…