Skip to content

Commit

Permalink
Merge pull request #669 from madsmtm/exception-unwind
Browse files Browse the repository at this point in the history
Fix Rust panics in `exception::catch`, and make it safe
  • Loading branch information
madsmtm authored Nov 15, 2024
2 parents 4ec407e + 74dda7a commit 4ea22f2
Show file tree
Hide file tree
Showing 7 changed files with 111 additions and 62 deletions.
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

0 comments on commit 4ea22f2

Please sign in to comment.