objc2/
exception.rs

1//! # `@throw` and `@try/@catch` exceptions.
2//!
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.
12//!
13//! Most of the functionality in this module is only available when the
14//! `"exception"` feature is enabled.
15//!
16//! See the following links for more information:
17//! - [Exception Programming Topics for Cocoa](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Exceptions/Exceptions.html)
18//! - [The Objective-C Programming Language - Exception Handling](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjectiveC/Chapters/ocExceptionHandling.html)
19//! - [Exception Handling in LLVM](https://llvm.org/docs/ExceptionHandling.html)
20//!
21//! [`msg_send!`]: crate::msg_send
22
23// TODO: Test this with panic=abort, and ensure that the code-size is
24// reasonable in that case.
25
26#[cfg(feature = "exception")]
27use core::ffi::c_void;
28use core::ffi::CStr;
29use core::fmt;
30#[cfg(feature = "exception")]
31use core::mem;
32use core::ops::Deref;
33use core::panic::RefUnwindSafe;
34use core::panic::UnwindSafe;
35#[cfg(feature = "exception")]
36use core::ptr;
37use std::error::Error;
38
39use crate::encode::{Encoding, RefEncode};
40#[cfg(feature = "exception")]
41use crate::ffi;
42#[cfg(feature = "catch-all")]
43use crate::ffi::NSUInteger;
44#[cfg(feature = "catch-all")]
45use crate::msg_send;
46use crate::rc::{autoreleasepool_leaking, Retained};
47use crate::runtime::__nsstring::nsstring_to_str;
48use crate::runtime::{AnyClass, AnyObject, NSObject, NSObjectProtocol};
49use crate::{extern_methods, sel, Message};
50
51/// An Objective-C exception.
52///
53/// While highly recommended that any exceptions you intend to throw are
54/// subclasses of `NSException`, this is not required by the runtime (similar
55/// to how Rust can panic with arbitrary payloads using [`panic_any`]).
56///
57/// [`panic_any`]: std::panic::panic_any
58#[repr(transparent)]
59pub struct Exception(AnyObject);
60
61unsafe impl RefEncode for Exception {
62    const ENCODING_REF: Encoding = Encoding::Object;
63}
64
65unsafe impl Message for Exception {}
66
67impl Deref for Exception {
68    type Target = AnyObject;
69
70    #[inline]
71    fn deref(&self) -> &AnyObject {
72        &self.0
73    }
74}
75
76impl AsRef<AnyObject> for Exception {
77    #[inline]
78    fn as_ref(&self) -> &AnyObject {
79        self
80    }
81}
82
83impl Exception {
84    fn is_nsexception(&self) -> Option<bool> {
85        if self.class().responds_to(sel!(isKindOfClass:)) {
86            // SAFETY: We only use `isKindOfClass:` on NSObject
87            let obj: *const Exception = self;
88            let obj = unsafe { obj.cast::<NSObject>().as_ref().unwrap() };
89            // Get class dynamically instead of with `class!` macro
90            let name = CStr::from_bytes_with_nul(b"NSException\0").unwrap();
91            Some(obj.isKindOfClass(AnyClass::get(name)?))
92        } else {
93            Some(false)
94        }
95    }
96
97    #[cfg(feature = "catch-all")]
98    pub(crate) fn stack_trace(&self) -> impl fmt::Display + '_ {
99        struct Helper<'a>(&'a Exception);
100
101        impl fmt::Display for Helper<'_> {
102            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103                if let Some(true) = self.0.is_nsexception() {
104                    autoreleasepool_leaking(|pool| {
105                        // SAFETY: The object is an `NSException`.
106                        // Returns `NSArray<NSString *>`.
107                        let call_stack_symbols: Option<Retained<NSObject>> =
108                            unsafe { msg_send![self.0, callStackSymbols] };
109                        if let Some(call_stack_symbols) = call_stack_symbols {
110                            writeln!(f, "stack backtrace:")?;
111
112                            // SAFETY: `call_stack_symbols` is an `NSArray`, and
113                            // `count` returns `NSUInteger`.
114                            let count: NSUInteger =
115                                unsafe { msg_send![&call_stack_symbols, count] };
116                            let mut i = 0;
117                            while i < count {
118                                // SAFETY: The index is in-bounds (so no exception will be thrown).
119                                let symbol: Retained<NSObject> =
120                                    unsafe { msg_send![&call_stack_symbols, objectAtIndex: i] };
121                                // SAFETY: The symbol is an NSString, and is not used
122                                // beyond this scope.
123                                let symbol = unsafe { nsstring_to_str(&symbol, pool) };
124                                writeln!(f, "{symbol}")?;
125                                i += 1;
126                            }
127                        }
128                        Ok(())
129                    })
130                } else {
131                    Ok(())
132                }
133            }
134        }
135
136        Helper(self)
137    }
138}
139
140impl Exception {
141    extern_methods!(
142        // Only safe on NSException
143        // Returns NSString
144        #[unsafe(method(name))]
145        #[unsafe(method_family = none)]
146        unsafe fn name(&self) -> Option<Retained<NSObject>>;
147
148        // Only safe on NSException
149        // Returns NSString
150        #[unsafe(method(reason))]
151        #[unsafe(method_family = none)]
152        unsafe fn reason(&self) -> Option<Retained<NSObject>>;
153    );
154}
155
156// Note: We can't implement `Send` nor `Sync` since the exception could be
157// anything!
158
159impl fmt::Debug for Exception {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        write!(f, "exception ")?;
162
163        // Attempt to present a somewhat usable error message if the exception
164        // is an instance of NSException.
165        if let Some(true) = self.is_nsexception() {
166            autoreleasepool_leaking(|pool| {
167                // SAFETY: Just checked that object is an NSException
168                let (name, reason) = unsafe { (self.name(), self.reason()) };
169
170                // SAFETY:
171                // - `name` and `reason` are guaranteed to be `NSString`s.
172                // - We control the scope in which they are alive, so we know
173                //   they are not moved outside the current autorelease pool.
174                //
175                // Note that these strings are immutable (`NSException` is
176                // immutable, and the properties are marked as `readonly` and
177                // `copy` and are copied upon creation), so we also don't have
178                // to worry about the string being mutated under our feet.
179                let name = name
180                    .as_deref()
181                    .map(|name| unsafe { nsstring_to_str(name, pool) });
182                let reason = reason
183                    .as_deref()
184                    .map(|reason| unsafe { nsstring_to_str(reason, pool) });
185
186                let obj: &AnyObject = self.as_ref();
187                write!(f, "{obj:?} '{}'", name.unwrap_or_default())?;
188                if let Some(reason) = reason {
189                    write!(f, " reason: {reason}")?;
190                } else {
191                    write!(f, " reason: (NULL)")?;
192                }
193                Ok(())
194            })
195        } else {
196            // Fall back to `AnyObject` Debug
197            write!(f, "{:?}", self.0)
198        }
199    }
200}
201
202impl fmt::Display for Exception {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        autoreleasepool_leaking(|pool| {
205            if let Some(true) = self.is_nsexception() {
206                // SAFETY: Just checked that object is an NSException
207                let reason = unsafe { self.reason() };
208
209                if let Some(reason) = &reason {
210                    // SAFETY: Same as above in `Debug`.
211                    let reason = unsafe { nsstring_to_str(reason, pool) };
212                    return write!(f, "{reason}");
213                }
214            }
215
216            write!(f, "unknown exception")
217        })
218    }
219}
220
221impl Error for Exception {}
222
223impl UnwindSafe for Exception {}
224impl RefUnwindSafe for Exception {}
225
226/// Throws an Objective-C exception.
227///
228/// This is the Objective-C equivalent of Rust's [`panic!`].
229///
230/// This unwinds from Objective-C, and the exception should be caught using an
231/// Objective-C exception handler like [`catch`]. It _may_ be caught by
232/// [`catch_unwind`], though the error message is unlikely to be great.
233///
234/// [`catch_unwind`]: std::panic::catch_unwind
235#[inline]
236#[cfg(feature = "exception")] // For consistency, not strictly required
237pub fn throw(exception: Retained<Exception>) -> ! {
238    // We consume the exception object since we can't make any guarantees
239    // about its mutability.
240    let ptr: *const AnyObject = &exception.0;
241    let ptr = ptr as *mut AnyObject;
242    // SAFETY: The object is valid and non-null (nil exceptions are not valid
243    // in the old runtime).
244    unsafe { ffi::objc_exception_throw(ptr) }
245}
246
247#[cfg(feature = "exception")]
248fn try_no_ret<F: FnOnce()>(closure: F) -> Result<(), Option<Retained<Exception>>> {
249    let f = {
250        extern "C-unwind" fn try_objc_execute_closure<F>(closure: &mut Option<F>)
251        where
252            F: FnOnce(),
253        {
254            // This is always passed Some, so it's safe to unwrap
255            let closure = closure.take().unwrap();
256            closure();
257        }
258
259        let f: extern "C-unwind" fn(&mut Option<F>) = try_objc_execute_closure;
260        let f: extern "C-unwind" fn(*mut c_void) = unsafe { mem::transmute(f) };
261        f
262    };
263
264    // Wrap the closure in an Option so it can be taken
265    let mut closure = Some(closure);
266    let context: *mut Option<F> = &mut closure;
267    let context = context.cast();
268
269    let mut exception = ptr::null_mut();
270    // SAFETY: The function pointer and context are valid.
271    //
272    // The exception catching itself is sound on the Rust side, because we
273    // correctly use `extern "C-unwind"`. Objective-C does not completely
274    // specify how foreign unwinds are handled, though they do have the
275    // `@catch (...)` construct intended for catching C++ exceptions, so it is
276    // likely that they intend to support Rust panics (and it works in
277    // practice).
278    //
279    // See also:
280    // https://github.com/rust-lang/rust/pull/128321
281    // https://github.com/rust-lang/reference/pull/1226
282    let success = unsafe { objc2_exception_helper::try_catch(f, context, &mut exception) };
283
284    if success == 0 {
285        Ok(())
286    } else {
287        // SAFETY:
288        // The exception is always a valid object or NULL.
289        //
290        // Since we do a retain in `objc2_exception_helper/src/try_catch.m`,
291        // the object has +1 retain count.
292        Err(unsafe { Retained::from_raw(exception.cast()) })
293    }
294}
295
296/// Tries to execute the given closure and catches an Objective-C exception
297/// if one is thrown.
298///
299/// This is the Objective-C equivalent of Rust's [`catch_unwind`].
300/// Accordingly, if your Rust code is compiled with `panic=abort`, or your
301/// Objective-C code with `-fno-objc-exceptions`, this cannot catch the
302/// exception.
303///
304/// [`catch_unwind`]: std::panic::catch_unwind
305///
306///
307/// # Errors
308///
309/// Returns a `Result` that is either `Ok` if the closure succeeded without an
310/// exception being thrown, or an `Err` with the exception. The exception is
311/// automatically released.
312///
313/// The exception is `None` in the extremely exceptional case that the
314/// exception object is `nil`. This should basically never happen, but is
315/// technically possible on some systems with `@throw nil`, or in OOM
316/// situations.
317///
318///
319/// # Panics
320///
321/// This panics if the given closure panics.
322///
323/// That is, it completely ignores Rust unwinding and simply lets that pass
324/// through unchanged.
325///
326/// It may also not catch all Objective-C exceptions (such as exceptions
327/// thrown when handling the memory management of the exception). These are
328/// mostly theoretical, and should only happen in utmost exceptional cases.
329#[cfg(feature = "exception")]
330pub fn catch<R>(
331    closure: impl FnOnce() -> R + UnwindSafe,
332) -> Result<R, Option<Retained<Exception>>> {
333    let mut value = None;
334    let value_ref = &mut value;
335    let closure = move || {
336        *value_ref = Some(closure());
337    };
338    let result = try_no_ret(closure);
339    // If the try succeeded, value was set so it's safe to unwrap
340    result.map(|()| value.unwrap_or_else(|| unreachable!()))
341}
342
343#[cfg(test)]
344#[cfg(feature = "exception")]
345mod tests {
346    use alloc::format;
347    use alloc::string::ToString;
348    use core::panic::AssertUnwindSafe;
349    use std::panic::catch_unwind;
350
351    use super::*;
352    use crate::msg_send;
353
354    #[test]
355    fn test_catch() {
356        let mut s = "Hello".to_string();
357        let result = catch(move || {
358            s.push_str(", World!");
359            s
360        });
361        assert_eq!(result.unwrap(), "Hello, World!");
362    }
363
364    #[test]
365    #[cfg_attr(
366        all(target_os = "macos", target_arch = "x86"),
367        ignore = "`NULL` exceptions are invalid on 32-bit / w. fragile runtime"
368    )]
369    fn test_catch_null() {
370        let s = "Hello".to_string();
371        let result = catch(move || {
372            if !s.is_empty() {
373                unsafe { ffi::objc_exception_throw(ptr::null_mut()) }
374            }
375            s.len()
376        });
377        assert!(result.unwrap_err().is_none());
378    }
379
380    #[test]
381    #[cfg_attr(
382        feature = "catch-all",
383        ignore = "Panics inside `catch` when catch-all is enabled"
384    )]
385    fn test_catch_unknown_selector() {
386        let obj = AssertUnwindSafe(NSObject::new());
387        let ptr = Retained::as_ptr(&obj);
388        let result = catch(|| {
389            let _: Retained<NSObject> = unsafe { msg_send![&*obj, copy] };
390        });
391        let err = result.unwrap_err().unwrap();
392
393        assert_eq!(
394            format!("{err}"),
395            format!("-[NSObject copyWithZone:]: unrecognized selector sent to instance {ptr:?}"),
396        );
397    }
398
399    #[test]
400    fn test_throw_catch_object() {
401        let obj = NSObject::new();
402        // TODO: Investigate why this is required on GNUStep!
403        let _obj2 = obj.clone();
404        let obj: Retained<Exception> = unsafe { Retained::cast_unchecked(obj) };
405        let ptr: *const Exception = &*obj;
406
407        let result = catch(|| throw(obj));
408        let obj = result.unwrap_err().unwrap();
409
410        assert_eq!(format!("{obj:?}"), format!("exception <NSObject: {ptr:p}>"));
411
412        assert!(ptr::eq(&*obj, ptr));
413    }
414
415    #[test]
416    #[ignore = "currently aborts"]
417    fn throw_catch_unwind() {
418        let obj = NSObject::new();
419        let obj: Retained<Exception> = unsafe { Retained::cast_unchecked(obj) };
420
421        let result = catch_unwind(|| throw(obj));
422        let _ = result.unwrap_err();
423    }
424
425    #[test]
426    #[should_panic = "test"]
427    #[cfg_attr(
428        all(target_os = "macos", target_arch = "x86", panic = "unwind"),
429        ignore = "panic won't start on 32-bit / w. fragile runtime, it'll just abort, since the runtime uses setjmp/longjump unwinding"
430    )]
431    fn does_not_catch_panic() {
432        let _ = catch(|| panic!("test"));
433    }
434}