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 16 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
13 changes: 12 additions & 1 deletion godot-core/src/init/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,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);

#[cfg(not(feature = "experimental-threads"))]
{
let main_thread = std::thread::current().id();
// Without experimental-features enabled, we can only print panics with godot_print! if the panic occurs on the main (godot) thread
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 @@ -65,7 +76,7 @@ pub unsafe fn __gdext_load_library<E: ExtensionLibrary>(
success as u8
};

let ctx = || "error when loading GDExtension library";
let ctx = || "error when loading GDExtension library".to_string();
let is_success = crate::private::handle_panic(ctx, 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
206 changes: 96 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,45 @@ pub fn extract_panic_message(err: Box<dyn std::any::Any + Send>) -> String {
}
}

fn format_panic_message(msg: String) -> String {
fn format_panic_message(_location: Option<&std::panic::Location<'_>>, mut msg: String) -> String {
if let Some(context) = get_gdext_panic_context() {
msg = format!("{msg}\nContext: {context}");
}

let prefix = if let Some(location) = _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(godot_print: impl 'static + Send + Sync + Fn() -> bool) {
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 = extract_panic_message(panic_info.payload());
let message = format_panic_message(panic_info.location(), message);
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 +275,76 @@ pub(crate) fn has_error_print_level(level: u8) -> bool {
ERROR_PRINT_LEVEL.load(atomic::Ordering::Relaxed) >= level
}

#[cfg(debug_assertions)]
struct ScopedFunctionStack(Vec<*const dyn Fn() -> String>);

#[cfg(debug_assertions)]
impl ScopedFunctionStack {
/// # Safety
/// Function must removed (using pop_function) before lifetime is invalidated.
unsafe fn push_function(&mut self, function: &dyn Fn() -> String) {
/// # Safety
/// The caller must ensure that the function isn't used past its original lifetime.
/// (Invariant must be held by push_function)
unsafe fn assume_static_lifetime(
value: &dyn Fn() -> String,
) -> &'static dyn Fn() -> String {
std::mem::transmute(value)
}
self.0.push(assume_static_lifetime(function) as *const _);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you use raw pointers, lifetimes are thrown away, so the extension with assume_static_lifetime is no longer needed. Just insert the raw pointer directly.

Furthermore, which function is unsafe depends a bit on how we lay out the responsibilities. I'd spontaneously have said that get_last() should be unsafe since it's the one introducing UB when dereferencing a dangling pointer, but on the other hand there's no real invariant the caller can uphold inside the panic hook; they rely on everything being done correctly in handle_panic.

As such, this probably makes sense?

@lilizoey opinions on this?

Copy link
Contributor Author

@KevinThierauf KevinThierauf Feb 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you use raw pointers, lifetimes are thrown away, so the extension with assume_static_lifetime is no longer needed. Just insert the raw pointer directly.

I'm not a hundred percent certain why, but inserting the raw pointer without the call to assume_static_lifetime results in a compiler error:

error: lifetime may not live long enough
   --> godot-core\src\private.rs:296:29
    |
287 |     unsafe fn push_function(&mut self, function: &dyn Fn() -> String) {
    |                                                  - let's call the lifetime of this reference `'1`
...
296 |         self.functions.push(function as *const dyn Fn() -> String);
    |                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ cast requires that `'1` must outlive `'static`
error: could not compile `godot-core` (lib) due to 1 previous error

Copy link
Contributor Author

@KevinThierauf KevinThierauf Feb 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it has to do with the lifetime of the function trait itself, but I'm not certain.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Furthermore, which function is unsafe depends a bit on how we lay out the responsibilities. I'd spontaneously have said that get_last() should be unsafe since it's the one introducing UB when dereferencing a dangling pointer, but on the other hand there's no real invariant the caller can uphold inside the panic hook; they rely on everything being done correctly in handle_panic.

As such, this probably makes sense?

@lilizoey opinions on this?

Honestly it's a bit of a tossup, at least one of push_function and get_last have to be unsafe for sure. I think this works as is though. The api isn't the easiest to use safely but we also dont use this anywhere else, so it's probably fine.

I think it has to do with the lifetime of the function trait itself, but I'm not certain.

Yeah, you can do

function as (*const dyn Fn() -> String) as (*const (dyn Fn() -> String + 'static))

Copy link
Member

@Bromeon Bromeon Feb 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still pending 🙂

First, let's not use as cast to go from reference to pointer, there's a dedicated function for it. Unfortunately, we can't use ptr::cast() for the second conversion, as that implicitly needs Sized for some reason.

Using intermediate variables for clarity, this then becomes:

    let function = std::ptr::from_ref(function);
    let function = function as *const (dyn Fn() -> String + 'static);

Sidenote: as _ casts are a rusty crowbar which is very dangerous, let's write types explicitly in this context, not with _. It's ironic that mem::transmute got a lint to specify types explicitly, but pointer bending at will is totally not seen as a problem, not even in clippy. (I'm not saying it should be unsafe, of course; a lint would be nice though).


fn pop_function(&mut self) {
self.0.pop();
}

fn get_last(&self) -> Option<String> {
self.0.last().cloned().map(|pointer| unsafe {
// 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
&*pointer
}())
}
}

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

// Value may return `None` even from panic hook if called from 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 +357,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 +386,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 +415,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
5 changes: 3 additions & 2 deletions itest/rust/src/framework/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ impl IntegrationTests {
// could not be caught, causing UB at the Godot FFI boundary (in practice, this will be a defined Godot crash with
// stack trace though).
godot_error!("GDScript test panicked");
godot::private::extract_panic_message(e);
godot::private::extract_panic_message(&e);
TestOutcome::Failed
}
};
Expand Down Expand Up @@ -327,7 +327,8 @@ fn print_test_pre(test_case: &str, test_file: String, last_file: &mut Option<Str
if flush {
// Flush in GDScript, because its own print may come sooner than Rust prints otherwise.
// (Strictly speaking, this can also happen from Rust, when Godot prints something. So far, it didn't though...)
godot::private::flush_stdout();
use std::io::Write;
std::io::stdout().flush().expect("flush stdout");
}
}

Expand Down
22 changes: 3 additions & 19 deletions itest/rust/src/object_tests/dynamic_call_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,26 +149,10 @@ fn dynamic_call_with_panic() {
assert_eq!(call_error.class_name(), Some("Object"));
assert_eq!(call_error.method_name(), "call");

let appendix = if cfg!(debug_assertions) {
let mut path = "itest/rust/src/object_tests/object_test.rs".to_string();

if cfg!(target_os = "windows") {
path = path.replace('/', "\\")
}

// Obtain line number dynamically, avoids tedious maintenance on code reorganization.
let line = ObjPayload::get_panic_line();

format!("\n at {path}:{line}")
} else {
String::new()
};

let expected_error_message = format!(
"godot-rust function call failed: Object::call(&\"do_panic\")\
let expected_error_message = "godot-rust function call failed: Object::call(&\"do_panic\")\
\n Source: ObjPayload::do_panic()\
\n Reason: [panic] do_panic exploded{appendix}"
);
\n Reason: do_panic exploded"
.to_string();

assert_eq!(call_error.to_string(), expected_error_message);

Expand Down
5 changes: 0 additions & 5 deletions itest/rust/src/object_tests/object_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -925,11 +925,6 @@ impl ObjPayload {
fn do_panic(&self) {
panic!("do_panic exploded");
}

// Obtain the line number of the panic!() call above; keep equidistant to do_panic() method.
pub fn get_panic_line() -> u32 {
line!() - 5
}
}

// ----------------------------------------------------------------------------------------------------------------------------------------------
Expand Down
Loading