Compartir vía


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:

  1. Se produce una excepción de Objective-C.
  2. 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.
  3. El entorno de ejecución de Objective-C no encuentra ningún controlador @catch, llama a NSGetUncaughtExceptionHandler e invoca al controlador instalado por Xamarin.iOS/Xamarin.Mac.
  4. 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:

  1. Se produce una excepción de Objective-C.
  2. 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.
  3. 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 es ThrowObjectiveCException en .NET. En el caso de los proyectos de Xamarin heredados, es ThrowObjectiveCException 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á como UnwindNativeCode.

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 es ThrowManagedException en .NET. En el caso de los proyectos de Xamarin heredados, es ThrowManagedException 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).