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

Fix Rust panics in exception::catch, and make it safe #669

Merged
merged 3 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions crates/objc2-exception-helper/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Fixed
* Fixed the symbol name to include the correct SemVer version of the crate.
* Fixed the ABI of `try_catch` to be `extern "C-unwind"`.
* Clarified that `try_catch` does not catch Rust panics.


## 0.1.0 - 2024-06-02
Expand Down
11 changes: 9 additions & 2 deletions crates/objc2-exception-helper/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ use core::ffi::c_void;

type TryCatchClosure = extern "C-unwind" fn(*mut c_void);

// `try_catch` is deliberately `extern "C"`, we just prevented the unwind.
extern "C" {
// `try_catch` is `extern "C-unwind"`, since it does not use `@catch (...)`,
// but instead let unhandled exceptions pass through.
extern "C-unwind" {
/// Call the given function inside an Objective-C `@try/@catch` block.
///
/// Defined in `src/try_catch.m` and compiled in `build.rs`.
Expand All @@ -37,6 +38,12 @@ extern "C" {
/// (using mechanisms like core::intrinsics::r#try) is not an option!
///
/// [manual-asm]: https://gitlab.com/objrs/objrs/-/blob/b4f6598696b3fa622e6fddce7aff281770b0a8c2/src/exception.rs
///
///
/// # Panics
///
/// This panics / continues unwinding if the unwind is not triggered by an
/// Objective-C exception (i.e. it was triggered by Rust/C++/...).
#[link_name = "objc2_exception_helper_0_1_try_catch"]
pub fn try_catch(f: TryCatchClosure, context: *mut c_void, error: *mut *mut c_void) -> u8;
}
Expand Down
20 changes: 20 additions & 0 deletions crates/objc2-exception-helper/src/try_catch.m
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,27 @@ unsigned char objc2_exception_helper_0_1_try_catch(void (*f)(void *), void *cont
} @catch (id exception) {
// The exception is retained while inside the `@catch` block, but is
// not guaranteed to be so outside of it; so hence we must do it here!
//
// Code throwing an exception know that they don't hold sole access to
// that object any more, so it's certainly safe to retain.
*error = objc_retain(exception);
// NOTE: The above `retain` can throw, so an extra landing pad will be
// inserted here for that case.
return 1;
}
// NOTE: @catch(...) exists, but it only works reliably in 64-bit. On
// 32-bit, an exception may continue past that frame (I think).
// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Exceptions/Articles/Exceptions64Bit.html
//
// Given that there is no way for us to reliably catch all Rust/C++/...
// exceptions here, the best choice is to consistently let them continue
// unwinding up the stack frame.
//
// Besides, not re-throwing Rust exceptions is not currently supported,
// so we'd just get an `abort` if we _did_ try to handle it:
// https://github.com/rust-lang/rust/blob/80d0d927d5069b67cc08c0c65b48e7b6e0cdeeb5/library/std/src/panicking.rs#L51-L58
//
// In any case, this _is_ the behaviour we want, to just catch Objective-C
// exceptions, C++/Rust/... exceptions are better handled with
// `std::panic::catch_unwind`.
}
2 changes: 2 additions & 0 deletions crates/objc2/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
accept nullable function pointers.
* **BREAKING**: Changed the signature of various `ffi` functions to use the
proper `Bool` type instead of a typedef.
* Made `exception::catch` safe.

### Deprecated
* Merged and deprecated the following `ffi` types:
Expand Down Expand Up @@ -160,6 +161,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `AnyClass::instance_variable`.
- `AnyProtocol::get`.
- `AnyProtocol::name`.
* Clarified that `exception::catch` does not catch Rust panics.


## 0.5.2 - 2024-05-21
Expand Down
101 changes: 62 additions & 39 deletions crates/objc2/src/exception.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
//! # `@throw` and `@try/@catch` exceptions.
//!
//! By default, if the [`msg_send!`] macro causes an exception to be thrown,
//! this will unwind into Rust, resulting in undefined behavior. However, this
//! crate has an `"catch-all"` feature which, when enabled, wraps each
//! [`msg_send!`] in a `@catch` and panics if an exception is caught,
//! preventing Objective-C from unwinding into Rust.
//! By default, if a message send (such as those generated with the
//! [`msg_send!`] and [`extern_methods!`] macros) causes an exception to be
//! thrown, `objc2` will simply let it unwind into Rust.
//!
//! While not UB, it will likely end up aborting the process, since Rust
//! cannot catch foreign exceptions like Objective-C's. However, `objc2` has
//! the `"catch-all"` Cargo feature, which, when enabled, wraps each message
//! send in a `@catch` and instead panics if an exception is caught, which
//! might lead to slightly better error messages.
//!
//! Most of the functionality in this module is only available when the
//! `"exception"` feature is enabled.
Expand Down Expand Up @@ -239,7 +243,7 @@ pub fn throw(exception: Retained<Exception>) -> ! {
}

#[cfg(feature = "exception")]
unsafe fn try_no_ret<F: FnOnce()>(closure: F) -> Result<(), Option<Retained<Exception>>> {
fn try_no_ret<F: FnOnce()>(closure: F) -> Result<(), Option<Retained<Exception>>> {
let f = {
extern "C-unwind" fn try_objc_execute_closure<F>(closure: &mut Option<F>)
where
Expand All @@ -261,6 +265,18 @@ unsafe fn try_no_ret<F: FnOnce()>(closure: F) -> Result<(), Option<Retained<Exce
let context = context.cast();

let mut exception = ptr::null_mut();
// SAFETY: The function pointer and context are valid.
//
// The exception catching itself is sound on the Rust side, because we
// correctly use `extern "C-unwind"`. Objective-C does not completely
// specify how foreign unwinds are handled, though they do have the
// `@catch (...)` construct intended for catching C++ exceptions, so it is
// likely that they intend to support Rust panics (and it works in
// practice).
//
// See also:
// https://github.com/rust-lang/rust/pull/128321
// https://github.com/rust-lang/reference/pull/1226
let success = unsafe { objc2_exception_helper::try_catch(f, context, &mut exception) };

if success == 0 {
Expand All @@ -269,12 +285,8 @@ unsafe fn try_no_ret<F: FnOnce()>(closure: F) -> Result<(), Option<Retained<Exce
// SAFETY:
// The exception is always a valid object or NULL.
//
// Since we do a retain inside `extern/exception.m`, the object has
// +1 retain count.
//
// Code throwing an exception know that they don't hold sole access to
// that object any more, so even if the type was originally mutable,
// it is okay to create a new `Retained` to it here.
// Since we do a retain in `objc2_exception_helper/src/try_catch.m`,
// the object has +1 retain count.
Err(unsafe { Retained::from_raw(exception.cast()) })
}
}
Expand All @@ -283,8 +295,9 @@ unsafe fn try_no_ret<F: FnOnce()>(closure: F) -> Result<(), Option<Retained<Exce
/// if one is thrown.
///
/// This is the Objective-C equivalent of Rust's [`catch_unwind`].
/// Accordingly, if your Rust code is compiled with `panic=abort` this cannot
/// catch the exception.
/// Accordingly, if your Rust code is compiled with `panic=abort`, or your
/// Objective-C code with `-fno-objc-exceptions`, this cannot catch the
/// exception.
///
/// [`catch_unwind`]: std::panic::catch_unwind
///
Expand All @@ -301,20 +314,26 @@ unsafe fn try_no_ret<F: FnOnce()>(closure: F) -> Result<(), Option<Retained<Exce
/// situations.
///
///
/// # Safety
/// # Panics
///
/// This panics if the given closure panics.
///
/// That is, it completely ignores Rust unwinding and simply lets that pass
/// through unchanged.
///
/// The given closure must not panic (e.g. normal Rust unwinding into this
/// causes undefined behaviour).
/// It may also not catch all Objective-C exceptions (such as exceptions
/// thrown when handling the memory management of the exception). These are
/// mostly theoretical, and should only happen in utmost exceptional cases.
#[cfg(feature = "exception")]
pub unsafe fn catch<R>(
pub fn catch<R>(
closure: impl FnOnce() -> R + UnwindSafe,
) -> Result<R, Option<Retained<Exception>>> {
let mut value = None;
let value_ref = &mut value;
let closure = move || {
*value_ref = Some(closure());
};
let result = unsafe { try_no_ret(closure) };
let result = try_no_ret(closure);
// If the try succeeded, value was set so it's safe to unwrap
result.map(|()| value.unwrap_or_else(|| unreachable!()))
}
Expand All @@ -333,12 +352,10 @@ mod tests {
#[test]
fn test_catch() {
let mut s = "Hello".to_string();
let result = unsafe {
catch(move || {
s.push_str(", World!");
s
})
};
let result = catch(move || {
s.push_str(", World!");
s
});
assert_eq!(result.unwrap(), "Hello, World!");
}

Expand All @@ -349,14 +366,12 @@ mod tests {
)]
fn test_catch_null() {
let s = "Hello".to_string();
let result = unsafe {
catch(move || {
if !s.is_empty() {
ffi::objc_exception_throw(ptr::null_mut())
}
s.len()
})
};
let result = catch(move || {
if !s.is_empty() {
unsafe { ffi::objc_exception_throw(ptr::null_mut()) }
}
s.len()
});
assert!(result.unwrap_err().is_none());
}

Expand All @@ -368,11 +383,9 @@ mod tests {
fn test_catch_unknown_selector() {
let obj = AssertUnwindSafe(NSObject::new());
let ptr = Retained::as_ptr(&obj);
let result = unsafe {
catch(|| {
let _: Retained<NSObject> = msg_send_id![&*obj, copy];
})
};
let result = catch(|| {
let _: Retained<NSObject> = unsafe { msg_send_id![&*obj, copy] };
});
let err = result.unwrap_err().unwrap();

assert_eq!(
Expand All @@ -389,7 +402,7 @@ mod tests {
let obj: Retained<Exception> = unsafe { Retained::cast_unchecked(obj) };
let ptr: *const Exception = &*obj;

let result = unsafe { catch(|| throw(obj)) };
let result = catch(|| throw(obj));
let obj = result.unwrap_err().unwrap();

assert_eq!(format!("{obj:?}"), format!("exception <NSObject: {ptr:p}>"));
Expand All @@ -406,4 +419,14 @@ mod tests {
let result = catch_unwind(|| throw(obj));
let _ = result.unwrap_err();
}

#[test]
#[should_panic = "test"]
#[cfg_attr(
all(target_os = "macos", target_arch = "x86", panic = "unwind"),
ignore = "panic won't start on 32-bit / w. fragile runtime, it'll just abort, since the runtime uses setjmp/longjump unwinding"
)]
fn does_not_catch_panic() {
let _ = catch(|| panic!("test"));
}
}
13 changes: 5 additions & 8 deletions crates/objc2/src/runtime/message_receiver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,10 +424,7 @@ pub unsafe trait MessageReceiver: private::Sealed + Sized {
}

// SAFETY: Upheld by caller
//
// The @catch is safe since message sending primitives are guaranteed
// to do Objective-C compatible unwinding.
unsafe { conditional_try!(|| msg_send_primitive::send(receiver, sel, args)) }
conditional_try!(|| unsafe { msg_send_primitive::send(receiver, sel, args) })
}

/// Sends a message to a specific superclass with the given selector and
Expand Down Expand Up @@ -469,10 +466,10 @@ pub unsafe trait MessageReceiver: private::Sealed + Sized {
msg_send_check_class(superclass, sel, A::ENCODINGS, &R::ENCODING_RETURN);
}

// SAFETY: Same as in `send_message`
unsafe {
conditional_try!(|| msg_send_primitive::send_super(receiver, superclass, sel, args))
}
// SAFETY: Upheld by caller
conditional_try!(|| unsafe {
msg_send_primitive::send_super(receiver, superclass, sel, args)
})
}
}

Expand Down
24 changes: 11 additions & 13 deletions crates/tests/src/exception.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ fn throw_catch_raise_catch() {

let exc = autoreleasepool(|_| {
let exc = NSException::into_exception(exc);
let res = unsafe { catch(|| throw(exc)) };
let res = catch(|| throw(exc));
let exc = res.unwrap_err().unwrap();
let exc = NSException::from_exception(exc).unwrap();

Expand All @@ -33,14 +33,13 @@ fn throw_catch_raise_catch() {
assert_eq!(exc.retainCount(), 1);

let exc = autoreleasepool(|_| {
let inner = || {
let res = catch(|| {
autoreleasepool(|pool| {
let exc = unsafe { Retained::autorelease(exc, pool) };
exc.raise()
})
};
});

let res = unsafe { catch(inner) };
let exc = NSException::from_exception(res.unwrap_err().unwrap()).unwrap();

// Undesired: The inner pool _should_ have been drained on unwind, but
Expand Down Expand Up @@ -92,14 +91,15 @@ fn raise_catch() {

let exc = autoreleasepool(|pool| {
let exc = unsafe { Retained::autorelease(exc, pool) };
let inner = || {
let res = catch(|| {
if exc.name() == name {
exc.raise();
} else {
42
}
};
let res = unsafe { catch(inner) }.unwrap_err().unwrap();
})
.unwrap_err()
.unwrap();
assert_eq!(exc.retainCount(), 2);
res
});
Expand All @@ -120,12 +120,10 @@ fn raise_catch() {
ignore = "Panics inside `catch` when catch-all is enabled"
)]
fn catch_actual() {
let res = unsafe {
catch(|| {
let arr: Retained<NSArray<NSObject>> = NSArray::new();
let _obj: *mut NSObject = msg_send![&arr, objectAtIndex: 0usize];
})
};
let res = catch(|| {
let arr: Retained<NSArray<NSObject>> = NSArray::new();
let _obj: *mut NSObject = unsafe { msg_send![&arr, objectAtIndex: 0usize] };
});
let exc = res.unwrap_err().unwrap();

let name = "NSRangeException";
Expand Down