A panic in a native mod's hook names the mod and the hook

The generated dispatch loops over mod_api::entered, which marks the thread as
inside each mod's hook while it runs; the crash handler adds "in native mod
<id>, hook <hook>" to the panic's message, and the panic goes on unwinding as
before. The launcher's generated executable passes the bundles' ids to
mod_api::name_mods when the revision has it. Dispatch still takes
&[Box<dyn Hooks>].
This commit is contained in:
arismoko committed 2026-11-02 12:37:42 +00:00
1 parent 9eb0e230e3
commit 6fc2bd3256
6 files changed
+609 -365

No files matched your search

+93 -6
View File
@@ -20,6 +20,53 @@ thread_local! {
static CONTAINED: Cell<u32> = const { Cell::new(0) };
/// The message of the last panic `contain` caught on this thread, from the hook.
static CAUGHT: RefCell<Option<String>> = const { RefCell::new(None) };
/// The native mod hook this thread is inside, for the panic hook to name.
static IN_NATIVE_MOD: Cell<Option<NativeModCall>> = const { Cell::new(None) };
}
/// A call into one hook of a native mod.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NativeModCall {
/// The mod's `[mod] id`, None in a build that didn't name its mods.
pub id: Option<&'static str>,
/// Its place in dispatch order, which names it then.
pub index: usize,
pub hook: &'static str,
}
/// Marks this thread as inside `call` until the guard drops, which also happens while a panic
/// unwinds. A hook called from a hook is named by the inner call.
pub fn enter_native_mod(call: NativeModCall) -> NativeModGuard {
NativeModGuard {
outer: IN_NATIVE_MOD.with(|c| c.replace(Some(call))),
}
}
/// Puts back the call that `enter_native_mod` found.
#[must_use]
pub struct NativeModGuard {
outer: Option<NativeModCall>,
}
impl Drop for NativeModGuard {
fn drop(&mut self) {
IN_NATIVE_MOD.with(|c| c.set(self.outer));
}
}
/// `message`, with the native mod hook this thread is inside, if any.
fn attributed(message: String) -> String {
match IN_NATIVE_MOD.with(Cell::get) {
Some(NativeModCall {
id: Some(id), hook, ..
}) => format!("{message}\nin native mod {id}, hook {hook}"),
Some(NativeModCall {
id: None,
index,
hook,
}) => format!("{message}\nin native mod #{index}, hook {hook}"),
None => message,
}
}
/// Runs `f`, and returns a panic in it as an error with the panic's message instead of letting
@@ -418,10 +465,10 @@ fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
let text = payload_text(info.payload());
match info.location() {
attributed(match info.location() {
Some(at) => format!("panicked at {at}:\n{text}"),
None => format!("panicked:\n{text}"),
}
})
}
/// A `std::backtrace` as one line a frame, `function (file:line:col)`. Captured in a panic hook
@@ -537,7 +584,8 @@ pub fn worker_panic_report(info: &std::panic::PanicHookInfo, side: &'static str)
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
/// 101); on a worker thread (`Util.onThreadException`) a crash report too, and the game ends
/// with it; on other threads the error goes to the log, as vanilla's uncaught exception handler
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
/// does. The default hook still prints the panic. A panic inside `contain` is only logged. A
/// panic inside a native mod's hook names the mod and the hook.
pub fn install(home: PathBuf, side: &'static str) {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
@@ -676,6 +724,25 @@ mod tests {
}
}
#[test]
fn a_panic_in_a_native_mods_hook_names_it() {
let outer = enter_native_mod(NativeModCall {
id: None,
index: 1,
hook: "render",
});
let inner = enter_native_mod(NativeModCall {
id: Some("b"),
index: 0,
hook: "tick",
});
assert!(attributed("boom".into()).ends_with("\nin native mod b, hook tick"));
drop(inner);
assert!(attributed("boom".into()).ends_with("\nin native mod #1, hook render"));
drop(outer);
assert_eq!(attributed("boom".into()), "boom");
}
fn report() -> Report {
Report {
time: chrono::TimeZone::with_ymd_and_hms(&Local, 2026, 10, 14, 9, 5, 7).unwrap(),
@@ -787,17 +854,37 @@ mod tests {
#[test]
fn a_panic_becomes_a_report() {
// A real panic through a hook, as the game's would be.
let caught = std::sync::Arc::new(Mutex::new(None));
let caught = std::sync::Arc::new(Mutex::new(Vec::new()));
let sink = caught.clone();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if panic_message(info).contains("the test crash") {
*sink.lock().unwrap() = Some(panic_report(info, "client"));
sink.lock().unwrap().push(panic_report(info, "client"));
}
}));
let _ = std::panic::catch_unwind(|| panic!("the test crash"));
// The same panic inside a native mod's hook, which the hook sees before the unwind.
let _ = std::panic::catch_unwind(|| {
let _guard = enter_native_mod(NativeModCall {
id: Some("a"),
index: 0,
hook: "tick",
});
panic!("the test crash")
});
std::panic::set_hook(previous);
let report = caught.lock().unwrap().take().expect("the hook ran");
// A caught panic leaves the thread as it found it.
assert_eq!(attributed("boom".into()), "boom");
let mut reports = std::mem::take(&mut *caught.lock().unwrap());
assert_eq!(reports.len(), 2, "the hook ran for both panics");
let in_mod = reports.pop().unwrap();
assert!(
in_mod.error.ends_with("\nin native mod a, hook tick"),
"{}",
in_mod.error
);
let report = reports.pop().unwrap();
assert!(!report.error.contains("native mod"));
assert!(report.error.contains("the test crash"));
assert!(report.error.contains("crash.rs"));
// The first frame is the test's own code, not the panic machinery.
+92
View File
@@ -400,6 +400,51 @@ pub fn mods() -> &'static [Box<dyn Hooks>] {
MODS.get().map_or(&[], |mods| mods.as_ref())
}
/// The bundles' `[mod] id`s, one a [`mods`] entry in the same order.
static MOD_IDS: OnceLock<&'static [&'static str]> = OnceLock::new();
/// Names the entries of [`mods`] by their bundles' `[mod] id`s, so a panic in a hook names its mod.
/// The launcher's generated executable calls it before `entry::main_with`.
pub fn name_mods(ids: &'static [&'static str]) {
if MOD_IDS.set(ids).is_err() {
log::warn!("Trait mods already named; ignoring the second list");
}
}
/// `mods` in order, each marked as running `hook` until the next one is (or the loop ends), so a
/// panic in it names the mod. The generated dispatch loops over it.
fn entered<'a>(mods: &'a [Box<dyn Hooks>], hook: &'static str) -> Entered<'a> {
Entered {
mods: mods.iter().enumerate(),
hook,
guard: None,
}
}
struct Entered<'a> {
mods: std::iter::Enumerate<std::slice::Iter<'a, Box<dyn Hooks>>>,
hook: &'static str,
guard: Option<crate::crash::NativeModGuard>,
}
impl<'a> Iterator for Entered<'a> {
type Item = &'a Box<dyn Hooks>;
fn next(&mut self) -> Option<Self::Item> {
// The last mod's mark goes first, so the next one's puts back what was there before.
drop(self.guard.take());
let (index, hooks) = self.mods.next()?;
self.guard = Some(crate::crash::enter_native_mod(
crate::crash::NativeModCall {
id: MOD_IDS.get().and_then(|ids| ids.get(index)).copied(),
index,
hook: self.hook,
},
));
Some(hooks)
}
}
/// Whether a mod claims a cancellable hook.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Flow {
@@ -492,3 +537,50 @@ mod generated;
pub use generated::Hooks;
#[doc(hidden)]
pub use generated::*;
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct Quiet;
impl Hooks for Quiet {}
struct Broken;
impl Hooks for Broken {
fn component_types(&self, _event: &mut ComponentTypesEvent<'_>) {
panic!("a broken mod");
}
}
#[test]
fn a_panic_in_a_hook_names_the_mod_and_the_hook() {
name_mods(&["quiet", "broken"]);
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Quiet), Box::new(Broken)];
let caught = Arc::new(Mutex::new(Vec::new()));
let sink = caught.clone();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let error = crate::crash::panic_report(info, "client").error;
if error.contains("a broken mod") {
sink.lock().unwrap().push(error);
}
}));
let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
dispatch_component_types(&mods, &mut Vec::new())
}));
// Once the dispatch has unwound, the thread is in no mod.
let _ = std::panic::catch_unwind(|| panic!("a broken mod's caller"));
std::panic::set_hook(previous);
// The game stops as before: the panic goes on to the dispatch's caller.
assert!(unwound.is_err());
let errors = caught.lock().unwrap();
assert_eq!(errors.len(), 2);
assert!(
errors[0].ends_with("\nin native mod broken, hook component_types"),
"{}",
errors[0]
);
assert!(!errors[1].contains("in native mod"), "{}", errors[1]);
}
}
File diff suppressed because it is too large. Load diff
+6
View File
@@ -2148,6 +2148,12 @@ another mod's hook object is not exposed.
Trait dispatch follows dependency order, with ties kept in bundle order. Mixin injection order remains
horizon-mixins' priority, mod ID, then injection ID; dependency order does not change injection order.
A panic in a trait mod's hook adds `in native mod <id>, hook <hook>` to the panic's message, in the crash report
and in the server thread's log line, with the bundle's `[mod] id` (the generated executable passes the ids to
`mod_api::name_mods`; a build that doesn't gives the mod's place in dispatch order, `#0` first). The game still
stops as before. Render hooks and trait injections are not named yet.
A bundle with an `[api]` package may declare its own injection in the trait:
```toml
+42 -5
View File
@@ -79,10 +79,24 @@ fn path_dep(path: &str) -> toml::Value {
toml::Value::Table(toml::Table::from_iter([("path".into(), path.into())]))
}
fn main_rs(count: usize) -> String {
let entries = (0..count)
/// Whether the staged revision names its trait mods (`mod_api::name_mods`, which a panic in a
/// hook names its mod by); an older one takes the list alone.
fn names_mods(stage: &Path) -> bool {
fs::read_to_string(stage.join("crates/horizon-client/src/mod_api.rs"))
.is_ok_and(|text| text.contains("pub fn name_mods("))
}
/// The generated executable's `main`: the trait mods of bundles `ids`, in that order, named by
/// those ids when `named`.
fn main_rs(ids: &[&str], named: bool) -> String {
let entries = (0..ids.len())
.map(|i| format!(" mod_{i}::hooks(),\n"))
.collect::<String>();
let naming = if named {
format!(" horizon_client::mod_api::name_mods(&{ids:?});\n")
} else {
String::new()
};
// Mirrors crates/horizon-client/src/main.rs, with trait mods passed to main_with.
format!(
r#"//! Generated by the launcher.
@@ -97,7 +111,7 @@ fn main() {{
std::hint::black_box(&MOD_API_LEVEL);
let mods: horizon_client::mod_api::Mods = vec![
{entries} ].into();
horizon_client::entry::main_with(mods);
{naming} horizon_client::entry::main_with(mods);
}}
"#
)
@@ -258,7 +272,9 @@ pub(super) fn stage(
)])),
);
write(&game.join("Cargo.toml"), &manifest)?;
fs::write(game.join("src/main.rs"), main_rs(mods.len())).map_err(|e| e.to_string())?;
let ids: Vec<_> = mods.iter().map(|(_, bundle)| bundle.id.as_str()).collect();
fs::write(game.join("src/main.rs"), main_rs(&ids, names_mods(stage)))
.map_err(|e| e.to_string())?;
let root = stage.join("Cargo.toml");
let mut workspace = read(&root)?;
let list = workspace
@@ -330,16 +346,25 @@ mod tests {
#[test]
fn generated_main_keeps_mod_order() {
let ids = ["base", "extra"];
for count in 0..=2 {
let text = main_rs(count);
let text = main_rs(&ids[..count], false);
assert!(text.contains("entry::main_with(mods)"));
assert_eq!(text.matches("::hooks()").count(), count);
assert!(!text.contains("name_mods"));
if count == 2 {
assert!(
text.find("mod_0::hooks()").unwrap() < text.find("mod_1::hooks()").unwrap()
);
}
}
// A revision that names its mods gets the ids in the same order, before main_with.
let named = main_rs(&ids, true);
let naming = named
.find(r#"mod_api::name_mods(&["base", "extra"]);"#)
.unwrap();
assert!(named.find("mod_1::hooks()").unwrap() < naming);
assert!(naming < named.find("entry::main_with(mods)").unwrap());
}
#[test]
@@ -397,6 +422,18 @@ mod tests {
game["features"]["default"][0].as_str(),
Some("horizon-client/default")
);
let main = || fs::read_to_string(stage_dir.join(GAME).join("src/main.rs")).unwrap();
assert!(!main().contains("name_mods"));
// A revision with `mod_api::name_mods` gets each bundle's id.
fs::create_dir_all(stage_dir.join("crates/horizon-client/src")).unwrap();
fs::write(
stage_dir.join("crates/horizon-client/src/mod_api.rs"),
"pub fn name_mods(ids: &'static [&'static str]) {}\n",
)
.unwrap();
let bundle = super::super::bundle(&dir, None).unwrap();
stage(&stage_dir, &[bundle], &[vec![]], &resolution).unwrap();
assert!(main().contains(r#"name_mods(&["example"]);"#));
fs::remove_dir_all(dir).unwrap();
}
+25 -3
View File
@@ -853,17 +853,23 @@ fn generate(root: &Path, check: bool) -> Result<(), String> {
"/// What [`{trait_name}::{name}`] is told. Fields may be added: the struct is `#[non_exhaustive]`.\n#[non_exhaustive]\npub struct {event_type}{generics} {{\n{fields}}}\n"
));
let build = format!("let mut {event} = {event_type} {{ {} }};", inits.join(", "));
// A trait mod's call is marked with the mod for a panic to name; render hooks are called bare.
let each = if is_render {
mod_list.clone()
} else {
format!("super::entered({mod_list}, \"{name}\")")
};
let body = match contract {
"cancel" => format!(
"if *{o} {{ return; }} {build} for {mod_item} in {mod_list} {{ if matches!({mod_item}.{name}({invoke}), Flow::Cancel) {{ *{o} = true; break; }} }} {write_back}",
"if *{o} {{ return; }} {build} for {mod_item} in {each} {{ if matches!({mod_item}.{name}({invoke}), Flow::Cancel) {{ *{o} = true; break; }} }} {write_back}",
o = out.unwrap()
),
"replace" => format!(
"if {o}.is_some() {{ return; }} {build} for {mod_item} in {mod_list} {{ if let Some({replacement}) = {mod_item}.{name}({invoke}) {{ *{o} = Some({replacement}); break; }} }} {write_back}",
"if {o}.is_some() {{ return; }} {build} for {mod_item} in {each} {{ if let Some({replacement}) = {mod_item}.{name}({invoke}) {{ *{o} = Some({replacement}); break; }} }} {write_back}",
o = out.unwrap()
),
_ => format!(
"{build} for {mod_item} in {mod_list} {{ {mod_item}.{name}({invoke}); }} {write_back}"
"{build} for {mod_item} in {each} {{ {mod_item}.{name}({invoke}); }} {write_back}"
),
};
dispatch.push_str(&format!(
@@ -1143,6 +1149,19 @@ mod tests {
));
}
use generated::*;
thread_local! {
/// The hooks each dispatch entered, a mod at a time.
static ENTERED: std::cell::RefCell<Vec<(usize, &'static str)>> = const { std::cell::RefCell::new(Vec::new()) };
}
fn entered<'a>(
mods: &'a [Box<dyn Hooks>],
hook: &'static str,
) -> impl Iterator<Item = &'a Box<dyn Hooks>> {
mods.iter().enumerate().map(move |(index, hooks)| {
ENTERED.with(|entered| entered.borrow_mut().push((index, hook)));
hooks
})
}
struct Mod {
id: u32,
calls: Arc<Mutex<Vec<u32>>>,
@@ -1317,6 +1336,9 @@ mod tests {
dispatch_mouse_button_pre(&mut TestHost, &mods, 0, true, 0, &mut canceled);
assert!(canceled);
assert_eq!(*calls.lock().unwrap(), [1]);
// Each call is entered under the hook's name; the cancelled one's is the last.
let entered = ENTERED.with(|entered| entered.take());
assert_eq!(entered, [(0, "mouse_button_pre")]);
}
#[test]