Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Panic handling: thread safety; set hook once and not repeatedly #1037

Open
wants to merge 26 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8e2e943
panic replacements
KevinThierauf Feb 6, 2025
0e1d6f5
flush
KevinThierauf Feb 6, 2025
78193bd
re-export set_gdext_hook and get_gdext_panic_context
KevinThierauf Feb 6, 2025
3a6d430
cargo fmt
KevinThierauf Feb 6, 2025
1e3f2e9
clippy
KevinThierauf Feb 6, 2025
681cde9
fix typo
KevinThierauf Feb 6, 2025
24d557c
change formatted panic message for tests
KevinThierauf Feb 6, 2025
a1b239b
update dynamic_call_with_panic
KevinThierauf Feb 6, 2025
e827eaa
clippy
KevinThierauf Feb 6, 2025
8eeb62d
fixed (some) review notes
KevinThierauf Feb 7, 2025
d33975c
cargo fmt
KevinThierauf Feb 7, 2025
bf22726
ScopedFunctionStack
KevinThierauf Feb 9, 2025
8f3c96e
revert get_gdext_panic_context change; return Option instead of String
KevinThierauf Feb 9, 2025
8dd0041
clippy
KevinThierauf Feb 9, 2025
5b0b5c0
add backtrace to eprintln! in debug
KevinThierauf Feb 9, 2025
bd32724
Merge branch 'godot-rust:master' into master
KevinThierauf Feb 9, 2025
e0270e9
additional review fixes
KevinThierauf Feb 9, 2025
6389f14
some test modifications
KevinThierauf Feb 10, 2025
8715866
test formatted panic message
KevinThierauf Feb 10, 2025
c282385
Update godot-core/src/init/mod.rs
KevinThierauf Feb 11, 2025
b0d5a9c
fixes
KevinThierauf Feb 11, 2025
57abf8d
Apply suggestions from code review
KevinThierauf Feb 11, 2025
f7d10c1
fixes
KevinThierauf Feb 11, 2025
7323689
clippy
KevinThierauf Feb 11, 2025
e5f2db8
Merge branch 'godot-rust:master' into master
KevinThierauf Feb 14, 2025
5ce6318
fixed tests
KevinThierauf Feb 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions godot-core/src/init/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ pub unsafe fn __gdext_load_library<E: ExtensionLibrary>(
sys::initialize(get_proc_address, library, config);
}

// With experimental-features enabled, we can always print panics to godot_print!
#[cfg(feature = "experimental-threads")]
crate::private::set_gdext_hook(|| true);

// Without experimental-features enabled, we can only print panics with godot_print! if the panic occurs on the main (Godot) thread.
#[cfg(not(feature = "experimental-threads"))]
{
let main_thread = std::thread::current().id();
crate::private::set_gdext_hook(move || std::thread::current().id() == main_thread);
}

// Currently no way to express failure; could be exposed to E if necessary.
// No early exit, unclear if Godot still requires output parameters to be set.
let success = true;
Expand All @@ -68,8 +79,11 @@ pub unsafe fn __gdext_load_library<E: ExtensionLibrary>(
success as u8
};

let ctx = || "error when loading GDExtension library";
let is_success = crate::private::handle_panic(ctx, init_code);
// Use std::panic::catch_unwind instead of handle_panic: handle_panic uses TLS, which
// calls `thread_atexit` on linux, which sets the hot reloading flag on linux.
// Using std::panic::catch_unwind avoids this, although we lose out on context information
// for debugging.
let is_success = std::panic::catch_unwind(init_code);

is_success.unwrap_or(0)
}
Expand Down
1 change: 1 addition & 0 deletions godot-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub mod registry;
pub mod tools;

mod storage;
pub use crate::private::{get_gdext_panic_context, set_gdext_hook};
pub use godot_ffi as sys;

// ----------------------------------------------------------------------------------------------------------------------------------------------
Expand Down
210 changes: 100 additions & 110 deletions godot-core/src/private.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ use crate::global::godot_error;
use crate::meta::error::CallError;
use crate::meta::CallContext;
use crate::sys;
use std::cell::RefCell;
use std::io::Write;
use std::sync::atomic;
#[cfg(debug_assertions)]
use std::sync::{Arc, Mutex};
use sys::Global;

// ----------------------------------------------------------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -180,11 +180,6 @@ pub unsafe fn has_virtual_script_method(
sys::interface_fn!(object_has_script_method)(sys::to_const_ptr(object_ptr), method_sname) != 0
}

pub fn flush_stdout() {
use std::io::Write;
std::io::stdout().flush().expect("flush stdout");
}

/// Ensure `T` is an editor plugin.
pub const fn is_editor_plugin<T: crate::obj::Inherits<crate::classes::EditorPlugin>>() {}

Expand Down Expand Up @@ -221,15 +216,7 @@ pub fn is_class_runtime(is_tool: bool) -> bool {
// ----------------------------------------------------------------------------------------------------------------------------------------------
// Panic handling

#[cfg(debug_assertions)]
#[derive(Debug)]
struct GodotPanicInfo {
line: u32,
file: String,
//backtrace: Backtrace, // for future use
}

pub fn extract_panic_message(err: Box<dyn std::any::Any + Send>) -> String {
pub fn extract_panic_message(err: &(dyn Send + std::any::Any)) -> String {
if let Some(s) = err.downcast_ref::<&'static str>() {
s.to_string()
} else if let Some(s) = err.downcast_ref::<String>() {
Expand All @@ -239,18 +226,50 @@ pub fn extract_panic_message(err: Box<dyn std::any::Any + Send>) -> String {
}
}

fn format_panic_message(msg: String) -> String {
#[doc(hidden)]
pub fn format_panic_message(panic_info: &std::panic::PanicHookInfo) -> String {
let mut msg = extract_panic_message(panic_info.payload());

if let Some(context) = get_gdext_panic_context() {
msg = format!("{msg}\nContext: {context}");
}

let prefix = if let Some(location) = panic_info.location() {
format!("panic {}:{}", location.file(), location.line())
} else {
"panic".to_string()
};

// If the message contains newlines, print all of the lines after a line break, and indent them.
let lbegin = "\n ";
let indented = msg.replace('\n', lbegin);

if indented.len() != msg.len() {
format!("[panic]{lbegin}{indented}")
format!("[{prefix}]{lbegin}{indented}")
} else {
format!("[panic] {msg}")
format!("[{prefix}] {msg}")
}
}

pub fn set_gdext_hook<F>(godot_print: F)
where
F: Fn() -> bool + Send + Sync + 'static,
{
std::panic::set_hook(Box::new(move |panic_info| {
// Flush, to make sure previous Rust output (e.g. test announcement, or debug prints during app) have been printed.
let _ignored_result = std::io::stdout().flush();

let message = format_panic_message(panic_info);
if godot_print() {
godot_error!("{message}");
}
eprintln!("{message}");
#[cfg(debug_assertions)]
eprintln!("{}", std::backtrace::Backtrace::capture());
let _ignored_result = std::io::stderr().flush();
}));
}

pub fn set_error_print_level(level: u8) -> u8 {
assert!(level <= 2);
ERROR_PRINT_LEVEL.swap(level, atomic::Ordering::Relaxed)
Expand All @@ -261,19 +280,75 @@ pub(crate) fn has_error_print_level(level: u8) -> bool {
ERROR_PRINT_LEVEL.load(atomic::Ordering::Relaxed) >= level
}

/// Internal type used to store context information for debug purposes. Debug context is stored on the thread-local
/// ERROR_CONTEXT_STACK, which can later be used to retrieve the current context in the event of a panic. This value
/// probably shouldn't be used directly; use ['get_gdext_panic_context()'](get_gdext_panic_context) instead.
#[cfg(debug_assertions)]
struct ScopedFunctionStack {
functions: Vec<*const dyn Fn() -> String>,
}

#[cfg(debug_assertions)]
impl ScopedFunctionStack {
/// # Safety
/// Function must be removed (using [`pop_function()`](Self::pop_function)) before lifetime is invalidated.
unsafe fn push_function(&mut self, function: &dyn Fn() -> String) {
let function = std::ptr::from_ref(function);
#[allow(clippy::unnecessary_cast)]
let function = function as *const (dyn Fn() -> String + 'static);
self.functions.push(function);
}

fn pop_function(&mut self) {
self.functions.pop().expect("function stack is empty!");
}

fn get_last(&self) -> Option<String> {
self.functions.last().cloned().map(|pointer| {
// SAFETY:
// Invariants provided by push_function assert that any and all functions held by ScopedFunctionStack
// are removed before they are invalidated; functions must always be valid.
unsafe { (*pointer)() }
})
}
}

#[cfg(debug_assertions)]
thread_local! {
static ERROR_CONTEXT_STACK: RefCell<ScopedFunctionStack> = const {
RefCell::new(ScopedFunctionStack { functions: Vec::new() })
}
}

// Value may return `None`, even from panic hook, if called from a non-Godot thread.
pub fn get_gdext_panic_context() -> Option<String> {
#[cfg(debug_assertions)]
return ERROR_CONTEXT_STACK.with(|cell| cell.borrow().get_last());
#[cfg(not(debug_assertions))]
None
}

/// Executes `code`. If a panic is thrown, it is caught and an error message is printed to Godot.
///
/// Returns `Err(message)` if a panic occurred, and `Ok(result)` with the result of `code` otherwise.
///
/// In contrast to [`handle_varcall_panic`] and [`handle_ptrcall_panic`], this function is not intended for use in `try_` functions,
/// where the error is propagated as a `CallError` in a global variable.
pub fn handle_panic<E, F, R, S>(error_context: E, code: F) -> Result<R, String>
pub fn handle_panic<E, F, R>(error_context: E, code: F) -> Result<R, String>
where
E: FnOnce() -> S,
E: Fn() -> String,
F: FnOnce() -> R + std::panic::UnwindSafe,
S: std::fmt::Display,
{
handle_panic_with_print(error_context, code, has_error_print_level(1))
#[cfg(debug_assertions)]
ERROR_CONTEXT_STACK.with(|cell| unsafe {
// SAFETY: &error_context is valid for lifetime of function, and is removed from LAST_ERROR_CONTEXT before end of function.
cell.borrow_mut().push_function(&error_context)
});
let result =
std::panic::catch_unwind(code).map_err(|payload| extract_panic_message(payload.as_ref()));
#[cfg(debug_assertions)]
ERROR_CONTEXT_STACK.with(|cell| cell.borrow_mut().pop_function());
result
}

// TODO(bromeon): make call_ctx lazy-evaluated (like error_ctx) everywhere;
Expand All @@ -286,7 +361,7 @@ pub fn handle_varcall_panic<F, R>(
F: FnOnce() -> Result<R, CallError> + std::panic::UnwindSafe,
{
let outcome: Result<Result<R, CallError>, String> =
handle_panic_with_print(|| call_ctx, code, false);
handle_panic(|| format!("{call_ctx}"), code);

let call_error = match outcome {
// All good.
Expand Down Expand Up @@ -315,7 +390,7 @@ pub fn handle_ptrcall_panic<F, R>(call_ctx: &CallContext, code: F)
where
F: FnOnce() -> R + std::panic::UnwindSafe,
{
let outcome: Result<R, String> = handle_panic_with_print(|| call_ctx, code, false);
let outcome: Result<R, String> = handle_panic(|| format!("{call_ctx}"), code);

let call_error = match outcome {
// All good.
Expand Down Expand Up @@ -344,91 +419,6 @@ fn report_call_error(call_error: CallError, track_globally: bool) -> i32 {
}
}

fn handle_panic_with_print<E, F, R, S>(error_context: E, code: F, print: bool) -> Result<R, String>
where
E: FnOnce() -> S,
F: FnOnce() -> R + std::panic::UnwindSafe,
S: std::fmt::Display,
{
#[cfg(debug_assertions)]
let info: Arc<Mutex<Option<GodotPanicInfo>>> = Arc::new(Mutex::new(None));

// Back up previous hook, set new one.
#[cfg(debug_assertions)]
let prev_hook = {
let info = info.clone();
let prev_hook = std::panic::take_hook();

std::panic::set_hook(Box::new(move |panic_info| {
if let Some(location) = panic_info.location() {
*info.lock().unwrap() = Some(GodotPanicInfo {
file: location.file().to_string(),
line: location.line(),
//backtrace: Backtrace::capture(),
});
} else {
eprintln!("panic occurred, but can't get location information");
}
}));

prev_hook
};

// Run code that should panic, restore hook.
let panic = std::panic::catch_unwind(code);

// Restore the previous panic hook if in Debug mode.
#[cfg(debug_assertions)]
std::panic::set_hook(prev_hook);

match panic {
Ok(result) => Ok(result),
Err(err) => {
// Flush, to make sure previous Rust output (e.g. test announcement, or debug prints during app) have been printed
// TODO write custom panic handler and move this there, before panic backtrace printing.
flush_stdout();

// Handle panic info only in Debug mode.
#[cfg(debug_assertions)]
{
let msg = extract_panic_message(err);
let mut msg = format_panic_message(msg);

// Try to add location information.
if let Ok(guard) = info.lock() {
if let Some(info) = guard.as_ref() {
msg = format!("{}\n at {}:{}", msg, info.file, info.line);
}
}

if print {
godot_error!(
"Rust function panicked: {}\n Context: {}",
msg,
error_context()
);
//eprintln!("Backtrace:\n{}", info.backtrace);
}

Err(msg)
}

#[cfg(not(debug_assertions))]
{
let _ = error_context; // Unused warning.
let msg = extract_panic_message(err);
let msg = format_panic_message(msg);

if print {
godot_error!("{msg}");
}

Err(msg)
}
}
}
}

// ----------------------------------------------------------------------------------------------------------------------------------------------

#[cfg(test)]
Expand Down
2 changes: 1 addition & 1 deletion godot-macros/src/class/data_models/func.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ fn make_ptrcall_fn(call_ctx: &TokenStream, wrapped_method: &TokenStream) -> Toke
) {
let call_ctx = #call_ctx;
let _success = ::godot::private::handle_panic(
|| &call_ctx,
|| format!("{call_ctx}"),
|| #invocation
);

Expand Down
6 changes: 4 additions & 2 deletions itest/rust/src/builtin_tests/containers/callable_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ impl CallableRefcountTest {
#[cfg(since_api = "4.2")]
pub mod custom_callable {
use super::*;
use crate::framework::{assert_eq_self, quick_thread, ThreadCrosser};
use crate::framework::{assert_eq_self, quick_thread, suppress_panic_log, ThreadCrosser};
use godot::builtin::{Dictionary, RustCallable};
use godot::sys;
use godot::sys::GdextBuild;
Expand Down Expand Up @@ -543,7 +543,9 @@ pub mod custom_callable {
let received = Arc::new(AtomicU32::new(0));
let received_callable = received.clone();
let callable = Callable::from_local_fn("test", move |_args| {
panic!("TEST: {}", received_callable.fetch_add(1, Ordering::SeqCst))
suppress_panic_log(|| {
panic!("TEST: {}", received_callable.fetch_add(1, Ordering::SeqCst))
})
});

assert_eq!(Variant::nil(), callable.callv(&varray![]));
Expand Down
21 changes: 12 additions & 9 deletions itest/rust/src/framework/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use godot::classes::{Engine, Node, Os};
use godot::obj::Gd;
use godot::sys;
use std::collections::HashSet;
use std::panic;

mod bencher;
mod runner;
Expand Down Expand Up @@ -122,21 +123,23 @@ pub fn passes_filter(filters: &[String], test_name: &str) -> bool {
filters.is_empty() || filters.iter().any(|x| test_name.contains(x))
}

pub fn expect_panic(context: &str, code: impl FnOnce()) {
use std::panic;

pub fn suppress_panic_log<R>(callback: impl FnOnce() -> R) -> R {
// Exchange panic hook, to disable printing during expected panics. Also disable gdext's panic printing.
let prev_hook = panic::take_hook();
panic::set_hook(Box::new(|_panic_info| {}));
panic::set_hook(Box::new(
|_panic_info| { /* suppress panic hook; do nothing */ },
));
let prev_print_level = godot::private::set_error_print_level(0);
let res = callback();
panic::set_hook(prev_hook);
godot::private::set_error_print_level(prev_print_level);
res
}

pub fn expect_panic(context: &str, code: impl FnOnce()) {
// Generally, types should be unwind safe, and this helps ergonomics in testing (especially around &mut in expect_panic closures).
let code = panic::AssertUnwindSafe(code);

// Run code that should panic, restore hook + gdext panic printing.
let panic = panic::catch_unwind(code);
panic::set_hook(prev_hook);
godot::private::set_error_print_level(prev_print_level);
let panic = suppress_panic_log(move || panic::catch_unwind(code));

assert!(
panic.is_err(),
Expand Down
Loading
Loading