Rust's RefCell Reentrant Borrow Panic: The Listener That Tried to Subscribe

2026-05-18

This EventBus lets callers subscribe closures and emit events to all listeners. The compiler is happy, the API looks reasonable, and a unit test that just calls emit after a few subscriptions passes. Then a listener does something perfectly natural — registers another listener in response to an event — and the whole program face-plants.

use std::cell::RefCell;
use std::rc::Rc;

struct EventBus {
    listeners: RefCell<Vec<Box<dyn Fn(&str)>>>,
}

impl EventBus {
    fn new() -> Self {
        EventBus { listeners: RefCell::new(Vec::new()) }
    }

    fn subscribe(&self, listener: Box<dyn Fn(&str)>) {
        self.listeners.borrow_mut().push(listener);
    }

    fn emit(&self, event: &str) {
        for listener in self.listeners.borrow().iter() {
            listener(event);
        }
    }
}

fn main() {
    let bus = Rc::new(EventBus::new());
    let bus2 = Rc::clone(&bus);
    bus.subscribe(Box::new(move |e| {
        println!("got: {}", e);
        if e == "init" {
            bus2.subscribe(Box::new(|e2| println!("late: {}", e2)));
        }
    }));
    bus.emit("init"); // thread 'main' panicked: already borrowed
}

The Bug

emit calls self.listeners.borrow() and holds that immutable borrow for the entire for loop. Inside the loop, the listener invokes bus2.subscribe(...), which calls borrow_mut() on the same RefCell. RefCell enforces Rust's aliasing rules at runtime: an outstanding immutable borrow forbids any mutable borrow. The result is BorrowMutError — a panic, not a compile error.

This is the dynamic-checking analogue of "iterator invalidation." The trap is that the call graph is invisible at the borrow site. Any closure you don't control — a logging hook, a metric counter, a plugin — might reach back into the same RefCell. Even a single-threaded program written entirely in safe Rust can crash this way. Wrapping a RefCell in Rc makes the hazard worse, because clones of the Rc hand out new entry points to the same cell.

The fix is to not hold a borrow across calls to unknown code. The cleanest pattern is to release the borrow before dispatching, by snapshotting or swapping out the vec:

fn emit(&self, event: &str) {
    // Move listeners out, releasing the borrow before any callback runs.
    let taken = std::mem::take(&mut *self.listeners.borrow_mut());
    for listener in &taken {
        listener(event);
    }
    // Restore, prepending any listeners added during dispatch.
    let mut current = self.listeners.borrow_mut();
    let added = std::mem::replace(&mut *current, taken);
    current.extend(added);
}

Alternatives include cloning an Rc of each listener into a local Vec (if listeners are Rc-shared), or queueing subscribe requests into a pending vec that emit drains afterward. Whatever you choose, the rule is the same: drop the borrow before calling untrusted code.

A useful habit: treat every borrow() and borrow_mut() as opening a critical section, and ask "what's the longest call chain that runs before this guard is dropped?" If the answer includes "a user-supplied closure," you have a latent panic.

Key Takeaway: RefCell moves Rust's aliasing rules to runtime — holding a borrow across a callback can panic, so release the guard (snapshot, swap, or drop) before invoking code you don't fully control.

All newsletters