Skip to content

Commit 4ea22f2

Browse files
authored
Merge pull request #669 from madsmtm/exception-unwind
Fix Rust panics in `exception::catch`, and make it safe
2 parents 4ec407e + 74dda7a commit 4ea22f2

File tree

7 files changed

+111
-62
lines changed

7 files changed

+111
-62
lines changed

crates/objc2-exception-helper/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
88

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

1214

1315
## 0.1.0 - 2024-06-02

crates/objc2-exception-helper/src/lib.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ use core::ffi::c_void;
2020

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

23-
// `try_catch` is deliberately `extern "C"`, we just prevented the unwind.
24-
extern "C" {
23+
// `try_catch` is `extern "C-unwind"`, since it does not use `@catch (...)`,
24+
// but instead let unhandled exceptions pass through.
25+
extern "C-unwind" {
2526
/// Call the given function inside an Objective-C `@try/@catch` block.
2627
///
2728
/// Defined in `src/try_catch.m` and compiled in `build.rs`.
@@ -37,6 +38,12 @@ extern "C" {
3738
/// (using mechanisms like core::intrinsics::r#try) is not an option!
3839
///
3940
/// [manual-asm]: https://gitlab.com/objrs/objrs/-/blob/b4f6598696b3fa622e6fddce7aff281770b0a8c2/src/exception.rs
41+
///
42+
///
43+
/// # Panics
44+
///
45+
/// This panics / continues unwinding if the unwind is not triggered by an
46+
/// Objective-C exception (i.e. it was triggered by Rust/C++/...).
4047
#[link_name = "objc2_exception_helper_0_1_try_catch"]
4148
pub fn try_catch(f: TryCatchClosure, context: *mut c_void, error: *mut *mut c_void) -> u8;
4249
}

crates/objc2-exception-helper/src/try_catch.m

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,27 @@ unsigned char objc2_exception_helper_0_1_try_catch(void (*f)(void *), void *cont
1717
} @catch (id exception) {
1818
// The exception is retained while inside the `@catch` block, but is
1919
// not guaranteed to be so outside of it; so hence we must do it here!
20+
//
21+
// Code throwing an exception know that they don't hold sole access to
22+
// that object any more, so it's certainly safe to retain.
2023
*error = objc_retain(exception);
24+
// NOTE: The above `retain` can throw, so an extra landing pad will be
25+
// inserted here for that case.
2126
return 1;
2227
}
28+
// NOTE: @catch(...) exists, but it only works reliably in 64-bit. On
29+
// 32-bit, an exception may continue past that frame (I think).
30+
// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Exceptions/Articles/Exceptions64Bit.html
31+
//
32+
// Given that there is no way for us to reliably catch all Rust/C++/...
33+
// exceptions here, the best choice is to consistently let them continue
34+
// unwinding up the stack frame.
35+
//
36+
// Besides, not re-throwing Rust exceptions is not currently supported,
37+
// so we'd just get an `abort` if we _did_ try to handle it:
38+
// https://github.com/rust-lang/rust/blob/80d0d927d5069b67cc08c0c65b48e7b6e0cdeeb5/library/std/src/panicking.rs#L51-L58
39+
//
40+
// In any case, this _is_ the behaviour we want, to just catch Objective-C
41+
// exceptions, C++/Rust/... exceptions are better handled with
42+
// `std::panic::catch_unwind`.
2343
}

crates/objc2/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
9292
accept nullable function pointers.
9393
* **BREAKING**: Changed the signature of various `ffi` functions to use the
9494
proper `Bool` type instead of a typedef.
95+
* Made `exception::catch` safe.
9596

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

164166

165167
## 0.5.2 - 2024-05-21

crates/objc2/src/exception.rs

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

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

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

266282
if success == 0 {
@@ -269,12 +285,8 @@ unsafe fn try_no_ret<F: FnOnce()>(closure: F) -> Result<(), Option<Retained<Exce
269285
// SAFETY:
270286
// The exception is always a valid object or NULL.
271287
//
272-
// Since we do a retain inside `extern/exception.m`, the object has
273-
// +1 retain count.
274-
//
275-
// Code throwing an exception know that they don't hold sole access to
276-
// that object any more, so even if the type was originally mutable,
277-
// it is okay to create a new `Retained` to it here.
288+
// Since we do a retain in `objc2_exception_helper/src/try_catch.m`,
289+
// the object has +1 retain count.
278290
Err(unsafe { Retained::from_raw(exception.cast()) })
279291
}
280292
}
@@ -283,8 +295,9 @@ unsafe fn try_no_ret<F: FnOnce()>(closure: F) -> Result<(), Option<Retained<Exce
283295
/// if one is thrown.
284296
///
285297
/// This is the Objective-C equivalent of Rust's [`catch_unwind`].
286-
/// Accordingly, if your Rust code is compiled with `panic=abort` this cannot
287-
/// catch the exception.
298+
/// Accordingly, if your Rust code is compiled with `panic=abort`, or your
299+
/// Objective-C code with `-fno-objc-exceptions`, this cannot catch the
300+
/// exception.
288301
///
289302
/// [`catch_unwind`]: std::panic::catch_unwind
290303
///
@@ -301,20 +314,26 @@ unsafe fn try_no_ret<F: FnOnce()>(closure: F) -> Result<(), Option<Retained<Exce
301314
/// situations.
302315
///
303316
///
304-
/// # Safety
317+
/// # Panics
318+
///
319+
/// This panics if the given closure panics.
320+
///
321+
/// That is, it completely ignores Rust unwinding and simply lets that pass
322+
/// through unchanged.
305323
///
306-
/// The given closure must not panic (e.g. normal Rust unwinding into this
307-
/// causes undefined behaviour).
324+
/// It may also not catch all Objective-C exceptions (such as exceptions
325+
/// thrown when handling the memory management of the exception). These are
326+
/// mostly theoretical, and should only happen in utmost exceptional cases.
308327
#[cfg(feature = "exception")]
309-
pub unsafe fn catch<R>(
328+
pub fn catch<R>(
310329
closure: impl FnOnce() -> R + UnwindSafe,
311330
) -> Result<R, Option<Retained<Exception>>> {
312331
let mut value = None;
313332
let value_ref = &mut value;
314333
let closure = move || {
315334
*value_ref = Some(closure());
316335
};
317-
let result = unsafe { try_no_ret(closure) };
336+
let result = try_no_ret(closure);
318337
// If the try succeeded, value was set so it's safe to unwrap
319338
result.map(|()| value.unwrap_or_else(|| unreachable!()))
320339
}
@@ -333,12 +352,10 @@ mod tests {
333352
#[test]
334353
fn test_catch() {
335354
let mut s = "Hello".to_string();
336-
let result = unsafe {
337-
catch(move || {
338-
s.push_str(", World!");
339-
s
340-
})
341-
};
355+
let result = catch(move || {
356+
s.push_str(", World!");
357+
s
358+
});
342359
assert_eq!(result.unwrap(), "Hello, World!");
343360
}
344361

@@ -349,14 +366,12 @@ mod tests {
349366
)]
350367
fn test_catch_null() {
351368
let s = "Hello".to_string();
352-
let result = unsafe {
353-
catch(move || {
354-
if !s.is_empty() {
355-
ffi::objc_exception_throw(ptr::null_mut())
356-
}
357-
s.len()
358-
})
359-
};
369+
let result = catch(move || {
370+
if !s.is_empty() {
371+
unsafe { ffi::objc_exception_throw(ptr::null_mut()) }
372+
}
373+
s.len()
374+
});
360375
assert!(result.unwrap_err().is_none());
361376
}
362377

@@ -368,11 +383,9 @@ mod tests {
368383
fn test_catch_unknown_selector() {
369384
let obj = AssertUnwindSafe(NSObject::new());
370385
let ptr = Retained::as_ptr(&obj);
371-
let result = unsafe {
372-
catch(|| {
373-
let _: Retained<NSObject> = msg_send_id![&*obj, copy];
374-
})
375-
};
386+
let result = catch(|| {
387+
let _: Retained<NSObject> = unsafe { msg_send_id![&*obj, copy] };
388+
});
376389
let err = result.unwrap_err().unwrap();
377390

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

392-
let result = unsafe { catch(|| throw(obj)) };
405+
let result = catch(|| throw(obj));
393406
let obj = result.unwrap_err().unwrap();
394407

395408
assert_eq!(format!("{obj:?}"), format!("exception <NSObject: {ptr:p}>"));
@@ -406,4 +419,14 @@ mod tests {
406419
let result = catch_unwind(|| throw(obj));
407420
let _ = result.unwrap_err();
408421
}
422+
423+
#[test]
424+
#[should_panic = "test"]
425+
#[cfg_attr(
426+
all(target_os = "macos", target_arch = "x86", panic = "unwind"),
427+
ignore = "panic won't start on 32-bit / w. fragile runtime, it'll just abort, since the runtime uses setjmp/longjump unwinding"
428+
)]
429+
fn does_not_catch_panic() {
430+
let _ = catch(|| panic!("test"));
431+
}
409432
}

crates/objc2/src/runtime/message_receiver.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -424,10 +424,7 @@ pub unsafe trait MessageReceiver: private::Sealed + Sized {
424424
}
425425

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

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

472-
// SAFETY: Same as in `send_message`
473-
unsafe {
474-
conditional_try!(|| msg_send_primitive::send_super(receiver, superclass, sel, args))
475-
}
469+
// SAFETY: Upheld by caller
470+
conditional_try!(|| unsafe {
471+
msg_send_primitive::send_super(receiver, superclass, sel, args)
472+
})
476473
}
477474
}
478475

crates/tests/src/exception.rs

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ fn throw_catch_raise_catch() {
2222

2323
let exc = autoreleasepool(|_| {
2424
let exc = NSException::into_exception(exc);
25-
let res = unsafe { catch(|| throw(exc)) };
25+
let res = catch(|| throw(exc));
2626
let exc = res.unwrap_err().unwrap();
2727
let exc = NSException::from_exception(exc).unwrap();
2828

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

3535
let exc = autoreleasepool(|_| {
36-
let inner = || {
36+
let res = catch(|| {
3737
autoreleasepool(|pool| {
3838
let exc = unsafe { Retained::autorelease(exc, pool) };
3939
exc.raise()
4040
})
41-
};
41+
});
4242

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

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

9392
let exc = autoreleasepool(|pool| {
9493
let exc = unsafe { Retained::autorelease(exc, pool) };
95-
let inner = || {
94+
let res = catch(|| {
9695
if exc.name() == name {
9796
exc.raise();
9897
} else {
9998
42
10099
}
101-
};
102-
let res = unsafe { catch(inner) }.unwrap_err().unwrap();
100+
})
101+
.unwrap_err()
102+
.unwrap();
103103
assert_eq!(exc.retainCount(), 2);
104104
res
105105
});
@@ -120,12 +120,10 @@ fn raise_catch() {
120120
ignore = "Panics inside `catch` when catch-all is enabled"
121121
)]
122122
fn catch_actual() {
123-
let res = unsafe {
124-
catch(|| {
125-
let arr: Retained<NSArray<NSObject>> = NSArray::new();
126-
let _obj: *mut NSObject = msg_send![&arr, objectAtIndex: 0usize];
127-
})
128-
};
123+
let res = catch(|| {
124+
let arr: Retained<NSArray<NSObject>> = NSArray::new();
125+
let _obj: *mut NSObject = unsafe { msg_send![&arr, objectAtIndex: 0usize] };
126+
});
129127
let exc = res.unwrap_err().unwrap();
130128

131129
let name = "NSRangeException";

0 commit comments

Comments
 (0)