fix(runtime): a root actor panic escapes run()

The trampoline caught the root's panic, recorded it as Outcome::Panic on
the slot, and run() dropped the initial handle without reading it — every
assert inside run(), the standard test-suite pattern, was silently
vacuous (found live: a failing-first test passed). run() now reads the
root outcome before the handle drop and resume_unwinds the payload after
full teardown, so a caller's catch_unwind leaves the Runtime reusable;
Exit and Stopped return normally. The payload message is printed before
re-raising (the throw-site hook output was suppressed in-actor).

Correct the two tests this unmasked, both born failing and never run:
the select loser-arm test kept a closed arm in the set (the documented
closed-arm rule: a closed arm reports ready forever — observe the
disconnect and drop it); the send_after-to-dead test expected Ok(None)
from a closed+empty channel (documented: Err(RecvError), which proves
nothing-delivered even more strongly).
This commit is contained in:
Claude (sandbox)
2026-07-18 21:52:50 +00:00
parent 527f045e17
commit 7eae56a296
4 changed files with 85 additions and 3 deletions
+42
View File
@@ -1013,6 +1013,11 @@ pub fn init(config: Config) -> Runtime {
impl Runtime {
/// Run `f` as the initial actor, block until all actors finish.
/// Can be called multiple times sequentially on the same `Runtime`.
///
/// A panic in the initial actor propagates out of `run` (re-raised
/// after teardown, so the `Runtime` stays reusable even under a
/// caller's `catch_unwind`). Panics in other actors surface through
/// joins, links, monitors, and supervisors as usual.
pub fn run(&self, f: impl FnOnce() + Send + 'static) {
// Install smarm's panic hook on first call. The default Rust hook is
// not reentrant — concurrent actor panics can trigger a double-panic
@@ -1114,6 +1119,23 @@ impl Runtime {
let _ = t.join();
}
// The root's outcome, read before its handle drops (the outstanding
// handle pins the slot: no reclaim, no generation change under us).
// A root panic must escape `run()` — propagated at the tail below,
// after teardown. Dropping it unread here made every assert inside
// `run` silently vacuous (found live: a failing-first test passed).
let root_outcome = {
let slot = match self.inner.slot_at(initial_handle.pid()) {
Some(slot) => slot,
None => panic!("run(): root pid out of range"),
};
debug_assert!(
slot.status_for(initial_handle.pid()) == Status::Done,
"root not Done at run() teardown"
);
slot.cold.lock().outcome.take()
};
// Drop initial handle (decrements outstanding_handles count).
drop(initial_handle);
@@ -1147,6 +1169,26 @@ impl Runtime {
// Flush trace to disk (no-op without smarm-trace).
#[cfg(feature = "smarm-trace")]
crate::trace::flush();
// Propagate a root panic — last, after the full teardown above, so
// a `catch_unwind` around `run()` leaves the `Runtime` reusable.
// `Exit` and `Stopped` return normally: a cooperatively stopped
// root is not an error. `resume_unwind` re-raises the original
// payload; the throw-site hook output was suppressed in-actor, so
// the caller sees the payload message without the origin file:line.
if let Some(Outcome::Panic(payload)) = root_outcome {
// Surface the message: the throw-site hook output was suppressed
// in-actor, so without this a harness shows a bare FAILED.
let msg: Option<&str> = payload
.downcast_ref::<&'static str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(String::as_str));
eprintln!(
"smarm: root actor panicked: {}",
msg.unwrap_or("<non-string panic payload>")
);
std::panic::resume_unwind(payload);
}
}
/// Snapshot of runtime statistics for introspection / tests.
+32
View File
@@ -485,3 +485,35 @@ fn multi_thread_timer_only_no_pipe_contention() {
SLEEP_MS * 2,
);
}
// ---------------------------------------------------------------------------
// Root panic propagation
/// A panic in the root actor escapes `run()` to the caller. Anything else
/// makes every assert inside `run` silently vacuous — found live when a
/// failing-first test passed: the tripped assert was caught by the
/// trampoline, recorded as `Outcome::Panic` on the root slot, and dropped
/// unread with the initial handle.
#[test]
#[should_panic(expected = "root actor panic escapes")]
fn root_panic_escapes_run() {
rt1().run(|| {
panic!("root actor panic escapes");
});
}
/// Teardown completes before the root panic propagates: a caller that
/// catches it can immediately `run()` again on the same `Runtime` (the
/// documented sequential-reuse contract).
#[test]
fn runtime_reusable_after_root_panic() {
let r = rt1();
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
r.run(|| panic!("boom"));
}));
assert!(caught.is_err(), "root panic must escape run()");
let ran = Arc::new(AtomicBool::new(false));
let ran_t = ran.clone();
r.run(move || ran_t.store(true, Ordering::Relaxed));
assert!(ran.load(Ordering::Relaxed), "runtime unusable after root panic");
}
+7 -1
View File
@@ -108,8 +108,14 @@ fn loser_arm_wake_after_parked_select_stays_precise() {
t0.elapsed() >= Duration::from_millis(40),
"one-shot park returned early: a stale loser-arm wake landed"
);
// Arm 0 is now closed (the sender actor exited after its sends) and
// a closed arm reports ready forever under priority order — observe
// the disconnect and drop it from the set, per the documented
// closed-arm rule.
assert_eq!(select(&[&rxa, &rxb]), 0);
assert!(rxa.try_recv().is_err(), "arm 0 must report disconnect");
// The loser's message was never lost.
assert_eq!(select(&[&rxa, &rxb]), 1);
assert_eq!(select(&[&rxb]), 0);
assert_eq!(rxb.try_recv().unwrap(), Some(2));
h.join().unwrap();
});
+4 -2
View File
@@ -401,8 +401,10 @@ fn send_after_to_dead_typed_pid_is_silent() {
assert_eq!(report_rx.recv().unwrap(), 1); // sink has now exited
let _id = send_after(Duration::from_millis(15), sink, 2);
sleep(Duration::from_millis(45)); // let it fire against the dead pid
// No panic, and nothing further delivered.
assert_eq!(report_rx.try_recv(), Ok(None));
// No panic; the sink is gone, so its report sender dropped with it —
// closed+empty is Err (documented), which also proves nothing
// further was delivered.
assert!(report_rx.try_recv().is_err(), "nothing further delivered");
});
}