From 7eae56a296c35b89a18689735243a7c3a5815a1f Mon Sep 17 00:00:00 2001 From: "Claude (sandbox)" Date: Sat, 18 Jul 2026 21:52:50 +0000 Subject: [PATCH] fix(runtime): a root actor panic escapes run() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/runtime.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ tests/runtime.rs | 32 ++++++++++++++++++++++++++++++++ tests/select.rs | 8 +++++++- tests/timer.rs | 6 ++++-- 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/runtime.rs b/src/runtime.rs index c77b691..8b8d075 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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::().map(String::as_str)); + eprintln!( + "smarm: root actor panicked: {}", + msg.unwrap_or("") + ); + std::panic::resume_unwind(payload); + } } /// Snapshot of runtime statistics for introspection / tests. diff --git a/tests/runtime.rs b/tests/runtime.rs index b41445e..a3c95d6 100644 --- a/tests/runtime.rs +++ b/tests/runtime.rs @@ -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"); +} diff --git a/tests/select.rs b/tests/select.rs index 4249935..703cc05 100644 --- a/tests/select.rs +++ b/tests/select.rs @@ -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(); }); diff --git a/tests/timer.rs b/tests/timer.rs index 81fd0fd..8569ab1 100644 --- a/tests/timer.rs +++ b/tests/timer.rs @@ -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"); }); }