RefCell Reentrant Borrow Panic: The Listener That Tried to Subscribe2026-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
}
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.
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.
