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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user