Serialización de excepciones en Xamarin.iOS y Xamarin.Mac
Tanto el código administrado como Objective-C tienen compatibilidad con excepciones en tiempo de ejecución (cláusulas try/catch/finally).
Sin embargo, sus implementaciones son diferentes, lo que significa que las bibliotecas en tiempo de ejecución (el entorno de ejecución Mono o CoreCLR y las bibliotecas en tiempo de ejecución Objective-C) tienen problemas cuando tienen que controlar excepciones y luego ejecutar código escrito en otros lenguajes.
En este documento se explican los problemas que pueden producirse y las posibles soluciones.
También se incluye un proyecto de ejemplo, Exception Marshaling, que se puede usar para probar diferentes escenarios y sus soluciones.
Problema
El problema surge cuando se inicia una excepción y, durante el desenredado de la pila, se encuentra un marco que no coincide con el tipo de excepción que se produjo.
Un ejemplo típico de este problema es cuando una API nativa produce una excepción Objective-C y, luego, esa excepción Objective-C debe controlarse de alguna manera cuando el proceso de desenredado de la pila llega a un marco administrado.
En el caso de los proyectos de Xamarin heredados (anteriores a .NET), la acción predeterminada es no hacer nada.
En el ejemplo anterior, esto significa dejar que el entorno de ejecución Objective-C desenrede marcos administrados. Esta acción es problemática, ya que el entorno de ejecución de Objective-C no sabe cómo desenredar marcos administrados; por ejemplo, no ejecutará ninguna cláusula catch
ni finally
en ese marco.
Código interrumpido
Tenga en cuenta el siguiente código de ejemplo:
var dict = new NSMutableDictionary ();
dict.LowlevelSetObject (IntPtr.Zero, IntPtr.Zero);
Este código iniciará una excepción NSInvalidArgumentException de Objective-C en código nativo:
NSInvalidArgumentException *** setObjectForKey: key cannot be nil
Y el seguimiento de la pila será similar al siguiente:
0 CoreFoundation __exceptionPreprocess + 194
1 libobjc.A.dylib objc_exception_throw + 52
2 CoreFoundation -[__NSDictionaryM setObject:forKey:] + 1015
3 libobjc.A.dylib objc_msgSend + 102
4 TestApp ObjCRuntime.Messaging.void_objc_msgSend_IntPtr_IntPtr (intptr,intptr,intptr,intptr)
5 TestApp Foundation.NSMutableDictionary.LowlevelSetObject (intptr,intptr)
6 TestApp ExceptionMarshaling.Exceptions.ThrowObjectiveCException ()
Los marcos del 0 al 3 son marcos nativos y el desenredador de pila del entorno de ejecución de Objective-C puede desenredar esos marcos. En concreto, ejecutará cualquier cláusula Objective-C@catch
o @finally
.
Sin embargo, el desenredador de pila de Objective-C no es capaz de desenredar correctamente los marcos administrados (los marcos del 4 al 6): el desenredador de pila de Objective-C desenredará los marcos administrados, pero no ejecutará ninguna lógica de excepciones administrada (como las cláusulas catch
o "finally").
Esto significa que normalmente no es posible detectar estas excepciones de la siguiente manera:
try {
var dict = new NSMutableDictionary ();
dict.LowLevelSetObject (IntPtr.Zero, IntPtr.Zero);
} catch (Exception ex) {
Console.WriteLine (ex);
} finally {
Console.WriteLine ("finally");
}
El motivo es que el desenredador de pila de Objective-C no conoce la cláusula catch
administrada y tampoco se ejecutará la cláusula finally
.
Cuando el ejemplo de código anterior es eficaz es porque Objective-C tiene un método de notificación de excepciones de Objective-C no controladas, NSSetUncaughtExceptionHandler
, que Xamarin.iOS y Xamarin.Mac usan y, en ese momento, intenta convertir las excepciones de Objective-C en excepciones administradas.
Escenarios
Escenario 1: detección de excepciones de Objective-C con un controlador catch administrado
En el escenario siguiente, es posible detectar excepciones de Objective-C mediante controladores catch
administrados:
- Se produce una excepción de Objective-C.
- El entorno de ejecución de Objective-C recorre la pila (pero no la desenreda), buscando un controlador
@catch
nativo que pueda controlar la excepción. - El entorno de ejecución de Objective-C no encuentra ningún controlador
@catch
, llama aNSGetUncaughtExceptionHandler
e invoca al controlador instalado por Xamarin.iOS/Xamarin.Mac. - El controlador de Xamarin.iOS/Xamarin.Mac convertirá la excepción de Objective-C en una excepción administrada y la iniciará. Puesto que el entorno de ejecución de Objective-C no desenredó la pila (solo la recorrió), el marco actual es el mismo que donde se produjo la excepción de Objective-C.
Aquí hay otro problema, porque el entorno de ejecución Mono no sabe cómo desenredar marcos de Objective-C correctamente.
Cuando se llama a la devolución de llamada de la excepción de Objective-C no detectada de Xamarin.iOS, la pila es similar a la siguiente:
0 libxamarin-debug.dylib exception_handler(exc=name: "NSInvalidArgumentException" - reason: "*** setObjectForKey: key cannot be nil")
1 CoreFoundation __handleUncaughtException + 809
2 libobjc.A.dylib _objc_terminate() + 100
3 libc++abi.dylib std::__terminate(void (*)()) + 14
4 libc++abi.dylib __cxa_throw + 122
5 libobjc.A.dylib objc_exception_throw + 337
6 CoreFoundation -[__NSDictionaryM setObject:forKey:] + 1015
7 libxamarin-debug.dylib xamarin_dyn_objc_msgSend + 102
8 TestApp ObjCRuntime.Messaging.void_objc_msgSend_IntPtr_IntPtr (intptr,intptr,intptr,intptr)
9 TestApp Foundation.NSMutableDictionary.LowlevelSetObject (intptr,intptr) [0x00000]
10 TestApp ExceptionMarshaling.Exceptions.ThrowObjectiveCException () [0x00013]
Aquí, los únicos marcos administrados son los marcos del 8 al 10, pero la excepción administrada se produce en el marco 0. Esto significa que el entorno de ejecución Mono debe desenredar los marcos nativos del 0 al 7, lo que provoca un problema equivalente al descrito anteriormente: aunque el entorno de ejecución Mono desenrede los marcos nativos, no ejecutará ninguna cláusula Objective-C, @catch
o @finally
.
Ejemplo de código
-(id) setObject: (id) object forKey: (id) key
{
@try {
if (key == nil)
[NSException raise: @"NSInvalidArgumentException"];
} @finally {
NSLog (@"This won't be executed");
}
}
Y la cláusula @finally
no se ejecutará porque el entorno de ejecución Mono que desenreda este marco no la conoce.
Una variación de este escenario es iniciar una excepción administrada en código administrado y, a continuación, desenredarla mediante marcos nativos para llegar a la primera cláusula catch
administrada :
class AppDelegate : UIApplicationDelegate {
public override bool FinishedLaunching (UIApplication application, NSDictionary launchOptions)
{
throw new Exception ("An exception");
}
static void Main (string [] args)
{
try {
UIApplication.Main (args, null, typeof (AppDelegate));
} catch (Exception ex) {
Console.WriteLine ("Managed exception caught.");
}
}
}
El método UIApplication:Main
administrado llamará al método UIApplicationMain
nativo y, luego, iOS realizará gran parte de la ejecución de código nativo antes de llamar finalmente al método AppDelegate:FinishedLaunching
administrado, aún con muchos marcos nativos en la pila cuando se produzca la excepción administrada:
0: TestApp ExceptionMarshaling.IOS.AppDelegate:FinishedLaunching (UIKit.UIApplication,Foundation.NSDictionary)
1: TestApp (wrapper runtime-invoke) <Module>:runtime_invoke_bool__this___object_object (object,intptr,intptr,intptr)
2: libmonosgen-2.0.dylib mono_jit_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>, error=<unavailable>)
3: libmonosgen-2.0.dylib do_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>, error=<unavailable>)
4: libmonosgen-2.0.dylib mono_runtime_invoke [inlined] mono_runtime_invoke_checked(method=<unavailable>, obj=<unavailable>, params=<unavailable>, error=0xbff45758)
5: libmonosgen-2.0.dylib mono_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>)
6: libxamarin-debug.dylib xamarin_invoke_trampoline(type=<unavailable>, self=<unavailable>, sel="application:didFinishLaunchingWithOptions:", iterator=<unavailable>), context=<unavailable>)
7: libxamarin-debug.dylib xamarin_arch_trampoline(state=0xbff45ad4)
8: libxamarin-debug.dylib xamarin_i386_common_trampoline
9: UIKit -[UIApplication _handleDelegateCallbacksWithOptions:isSuspended:restoreState:]
10: UIKit -[UIApplication _callInitializationDelegatesForMainScene:transitionContext:]
11: UIKit -[UIApplication _runWithMainScene:transitionContext:completion:]
12: UIKit __84-[UIApplication _handleApplicationActivationWithScene:transitionContext:completion:]_block_invoke.3124
13: UIKit -[UIApplication workspaceDidEndTransaction:]
14: FrontBoardServices __37-[FBSWorkspace clientEndTransaction:]_block_invoke_2
15: FrontBoardServices __40-[FBSWorkspace _performDelegateCallOut:]_block_invoke
16: FrontBoardServices __FBSSERIALQUEUE_IS_CALLING_OUT_TO_A_BLOCK__
17: FrontBoardServices -[FBSSerialQueue _performNext]
18: FrontBoardServices -[FBSSerialQueue _performNextFromRunLoopSource]
19: FrontBoardServices FBSSerialQueueRunLoopSourceHandler
20: CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__
21: CoreFoundation __CFRunLoopDoSources0
22: CoreFoundation __CFRunLoopRun
23: CoreFoundation CFRunLoopRunSpecific
24: CoreFoundation CFRunLoopRunInMode
25: UIKit -[UIApplication _run]
26: UIKit UIApplicationMain
27: TestApp (wrapper managed-to-native) UIKit.UIApplication:UIApplicationMain (int,string[],intptr,intptr)
28: TestApp UIKit.UIApplication:Main (string[],intptr,intptr)
29: TestApp UIKit.UIApplication:Main (string[],string,string)
30: TestApp ExceptionMarshaling.IOS.Application:Main (string[])
Los marcos del 0 al 1 y del 27 al 30 son administrados, mientras que todos los marcos entre ellos son nativos.
Si Mono desenreda estos marcos, no se ejecutará ninguna cláusula Objective-C@catch
o @finally
.
Escenario 2: no se pueden detectar excepciones de Objective-C
En el escenario siguiente, no es posible detectar excepciones de Objective-C mediante controladores catch
administrados porque la excepción de Objective-C se controló de otra manera:
- Se produce una excepción de Objective-C.
- El entorno de ejecución de Objective-C recorre la pila (pero no la desenreda), buscando un controlador
@catch
nativo que pueda controlar la excepción. - El entorno de ejecución de Objective-C encuentra un controlador de
@catch
, desenreda la pila y comienza a ejecutar el controlador@catch
.
Este escenario se encuentra normalmente en aplicaciones de Xamarin.iOS, ya que en el subproceso principal suele haber código similar a este:
void UIApplicationMain ()
{
@try {
while (true) {
ExecuteRunLoop ();
}
} @catch (NSException *ex) {
NSLog (@"An unhandled exception occured: %@", exc);
abort ();
}
}
Esto significa que en el subproceso principal nunca hay realmente una excepción de Objective-C no controlada y, por tanto, nunca se llama a nuestra devolución de llamada que convierte las excepciones de Objective-C en excepciones administradas.
Este escenario también es habitual al depurar aplicaciones de Xamarin.Mac en una versión anterior de macOS que Xamarin.Mac admite, ya que al inspeccionar la mayoría de los objetos de interfaz de usuario en el depurador se intentarán capturar propiedades que corresponden a selectores que no existen en la plataforma en ejecución (ya que Xamarin.Mac incluye compatibilidad con una versión de macOS superior). Al llamar a estos selectores, se producirá una excepción NSInvalidArgumentException
("Selector no reconocido enviado a ..."), lo que finalmente hará que el proceso se bloquee.
En resumen, hacer que el entorno de ejecución de Objective-C o el de Mono desenreden marcos que no están programados para controlarse puede provocar comportamientos indefinidos, como bloqueos, fugas de memoria y otros tipos de comportamientos impredecibles (incorrectos).
Solución
En Xamarin.iOS 10 y Xamarin.Mac 2.10, se ha agregado compatibilidad con la detección de excepciones administradas y excepciones de Objective-C en cualquier límite nativo administrado y para convertir esa excepción en el otro tipo.
En pseudocódigo, tiene un aspecto similar al siguiente:
[DllImport (Constants.ObjectiveCLibrary)]
static extern void objc_msgSend (IntPtr handle, IntPtr selector);
static void DoSomething (NSObject obj)
{
objc_msgSend (obj.Handle, Selector.GetHandle ("doSomething"));
}
P/Invoke a objc_msgSend se intercepta y se llama a este código en su lugar:
void
xamarin_dyn_objc_msgSend (id obj, SEL sel)
{
@try {
objc_msgSend (obj, sel);
} @catch (NSException *ex) {
convert_to_and_throw_managed_exception (ex);
}
}
Y algo similar se hace en el caso inverso (serialización de excepciones administradas como excepciones de Objective-C).
La detección de excepciones en el límite nativo administrado no es rentable, por lo que para los proyectos de Xamarin heredados (anteriores a .NET), no siempre está habilitada de forma predeterminada:
- Xamarin.iOS/tvOS: la interceptación de excepciones de Objective-C está habilitada en el simulador.
- Xamarin.watchOS: la interceptación se aplica en todos los casos, ya que dejar que el entorno de ejecución de Objective-C desenrede los marcos administrados confundirá al recolector de elementos no utilizados y hará que se bloquee o no responda.
- Xamarin.Mac: la interceptación de excepciones de Objective-C está habilitada para las compilaciones de depuración.
En .NET, la serialización de excepciones administradas como excepciones de Objective-C siempre está habilitada de forma predeterminada.
En la sección Marcas de tiempo de compilación se explica cómo habilitar la interceptación cuando no está habilitada de forma predeterminada (o deshabilitarla cuando es el valor predeterminado).
Eventos
Hay dos eventos que se generan una vez que se intercepta una excepción: Runtime.MarshalManagedException
y Runtime.MarshalObjectiveCException
.
A ambos eventos se les pasa un objeto EventArgs
que contiene la excepción original que se produjo (la propiedad Exception
) y una propiedad ExceptionMode
para definir cómo se debe serializar la excepción.
La propiedad ExceptionMode
se puede cambiar en el controlador de eventos para modificar el comportamiento en consonancia con cualquier procesamiento personalizado realizado en el controlador. Un ejemplo sería anular el proceso si se produce una excepción determinada.
El cambio de la propiedad ExceptionMode
se aplica al evento único, no afecta a ninguna excepción interceptada en el futuro.
Los siguientes modos están disponibles al serializar excepciones administradas como código nativo:
Default
: el valor predeterminado varía según la plataforma. Siempre esThrowObjectiveCException
en .NET. En el caso de los proyectos de Xamarin heredados, esThrowObjectiveCException
si la recolección de elementos no utilizados está en modo cooperativo (watchOS); de lo contrario,UnwindNativeCode
(iOS/watchOS/macOS). El valor predeterminado puede cambiar en el futuro.UnwindNativeCode
: este es el comportamiento anterior (indefinido). No está disponible cuando se usa la recolección de elementos no utilizados en modo cooperativo (que es la única opción en watchOS; por lo tanto, no es una opción válida en watchOS), ni cuando se usa CoreCLR, pero es la opción predeterminada en todas las demás plataformas en proyectos de Xamarin heredados.ThrowObjectiveCException
: convierte la excepción administrada en una excepción Objective-C e inicia la excepción de Objective-C. Este es el valor predeterminado en .NET y en watchOS en proyectos de Xamarin heredados.Abort
: anula el proceso.Disable
: deshabilita la interceptación de excepciones, por lo que no tiene sentido establecer este valor en el controlador de eventos, pero una vez que el evento se genera, es demasiado tarde para deshabilitarlo. En cualquier caso, si se establece, se comportará comoUnwindNativeCode
.
Los siguientes modos están disponibles al serializar excepciones de Objective-C en código administrado:
Default
: el valor predeterminado varía según la plataforma. Siempre esThrowManagedException
en .NET. En el caso de los proyectos de Xamarin heredados, esThrowManagedException
si la recolección de elementos no utilizados está en modo cooperativo (watchOS); de lo contrario,UnwindManagedCode
(iOS/tvOS/macOS). El valor predeterminado puede cambiar en el futuro.UnwindManagedCode
: este es el comportamiento anterior (indefinido). No está disponible cuando se usa la recolección de elementos no utilizados en modo cooperativo (que es el único modo válido en watchOS; por lo tanto, no es una opción válida en watchOS), ni cuando se usa CoreCLR, pero es la opción predeterminada en todas las demás plataformas en proyectos de Xamarin heredados.ThrowManagedException
: convierte la excepción de Objective-C en una excepción administrada y la inicia. Este es el valor predeterminado en .NET y en watchOS en proyectos de Xamarin heredados.Abort
: anula el proceso.Disable
: deshabilita la interceptación de excepciones, por lo que no tiene sentido establecer este valor en el controlador de eventos, pero una vez que el evento se genera, es demasiado tarde para deshabilitarlo. En cualquier caso, si se establece, anulará el proceso.
Por lo tanto, para ver cada vez que se serializa una excepción, puede hacer esto:
Runtime.MarshalManagedException += (object sender, MarshalManagedExceptionEventArgs args) =>
{
Console.WriteLine ("Marshaling managed exception");
Console.WriteLine (" Exception: {0}", args.Exception);
Console.WriteLine (" Mode: {0}", args.ExceptionMode);
};
Runtime.MarshalObjectiveCException += (object sender, MarshalObjectiveCExceptionEventArgs args) =>
{
Console.WriteLine ("Marshaling Objective-C exception");
Console.WriteLine (" Exception: {0}", args.Exception);
Console.WriteLine (" Mode: {0}", args.ExceptionMode);
};
Marcas de tiempo de compilación
Es posible pasar las siguientes opciones a mtouch (para aplicaciones de Xamarin.iOS) y mmp (para aplicaciones de Xamarin.Mac), que determinarán si la interceptación de excepciones está habilitada y establecer la acción predeterminada que debe producirse:
--marshal-managed-exceptions=
default
unwindnativecode
throwobjectivecexception
abort
disable
--marshal-objectivec-exceptions=
default
unwindmanagedcode
throwmanagedexception
abort
disable
Excepto en el caso de disable
, estos valores son idénticos a los valores de ExceptionMode
que se pasan a los eventos MarshalManagedException
y MarshalObjectiveCException
.
La opción disable
deshabilitará gran parte de la interceptación, salvo que se interceptarán las excepciones cuando no se agregue ninguna sobrecarga de ejecución. Los eventos de serialización se siguen generando para estas excepciones, y el modo predeterminado es el modo predeterminado para la plataforma en ejecución.
Limitaciones
Solo se interceptan P/Invokes a la familia de funciones objc_msgSend
al intentar detectar excepciones de Objective-C. Esto significa que una función P/Invoke a otra función de C, que después inicia cualquier excepción de Objective-C, todavía se topará con el comportamiento antiguo e indefinido (esto puede mejorarse en el futuro).