mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
1 parent
9eb0e230e3
commit
6fc2bd3256
6 files changed
+609
-365
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
+351
-351
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user