Compartir a través de


Serialización de excepciones

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 (los entornos de ejecución de MonoVM/CoreCLR y las bibliotecas en tiempo de ejecución de Objective-C) tienen problemas cuando se producen excepciones de otros entornos de ejecución.

En este artículo se explican los problemas que pueden producirse y las posibles soluciones.

También incluye un proyecto de ejemplo, Serialización de excepciones, que se puede usar para probar diferentes escenarios y sus soluciones.

Problema

El problema se produce cuando se produce 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 de Objective-C y, a continuación, esa excepción Objective-C debe controlarse de alguna manera cuando el proceso de desenredado de la pila alcanza un marco administrado.

En el pasado (pre-.NET), la acción predeterminada era no hacer nada. Para el ejemplo anterior, esto significaría permitir que el runtime Objective-C desenrollara marcos administrados. Esta acción es problemática, ya que el entorno de ejecución de Objective-C no sabe cómo desempaquetar marcos gestionados; por ejemplo, no ejecutará ninguna catch o finally cláusula gestionada, lo que da lugar a errores extremadamente difíciles de encontrar.

Código roto

Tenga en cuenta el siguiente código de ejemplo:

var dict = new NSMutableDictionary ();
dict.LowlevelSetObject (IntPtr.Zero, IntPtr.Zero); 

Este código iniciará un Objective-C NSInvalidArgumentException en código nativo:

NSInvalidArgumentException *** setObjectForKey: key cannot be nil

El rastro de la pila será algo así:

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 0-3 son marcos nativos y el desenrollador de pila en el entorno de ejecución de Objective-C puede desenrollar esos marcos. En concreto, ejecutará cualquier cláusula Objective-C @catch o @finally.

Sin embargo, el desapilador de Objective-C no es capaz de desapilar correctamente los marcos administrados (marcos 4-6): el desapilador de Objective-C desapilará los marcos administrados, pero no ejecutará ninguna lógica de excepciones administradas (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");
}

Esto se debe a que el desenredador de Objective-C pila no conoce la cláusula administrada catch y tampoco se ejecutará la finally cláusula .

Cuando el ejemplo de código anterior es efectivo, se debe a que Objective-C tiene un método para recibir una notificación de excepciones de Objective-C no controladas, NSSetUncaughtExceptionHandler, que usan los SDK de .NET 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 siguiente escenario, es posible capturar excepciones Objective-C usando controladores administrados catch.

  1. Se produce una excepción de Objective-C.
  2. El entorno de ejecución Objective-C recorre la pila (pero no la desenrolla), buscando un controlador nativo @catch que pueda gestionar la excepción.
  3. El tiempo de ejecución de Objective-C no encuentra ningún @catch manejador, llama a NSGetUncaughtExceptionHandler e invoca el manejador instalado por el SDK de .NET.
  4. El controlador de los SDKs de .NET convertirá la excepción Objective-C en una excepción administrada y la lanzará. Dado que el entorno de ejecución de Objective-C no desenrolló la pila (solo lo recorrió), el marco de pila actual es el mismo que aquel en el que se lanzó la excepción Objective-C.

Se produce otro problema aquí, ya que el entorno de ejecución de Mono no sabe cómo desenrollar correctamente los marcos Objective-C.

Cuando se invoca la función de retorno en caso de excepción no detectada Objective-C en los SDK de .NET, la pila es como esta:

 0 TestApp                  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 TestApp                  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 fotogramas administrados son los fotogramas 8-10, pero la excepción administrada se produce en el fotograma 0. Esto significa que el tiempo de ejecución de Mono debe desenredar los fotogramas nativos 0-7, lo que provoca un problema equivalente al problema descrito anteriormente: aunque el tiempo de ejecución de Mono desenredará los fotogramas nativos, no ejecutará ninguna Objective-C @catch o @finally cláusulas.

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 @finally cláusula no se ejecutará porque el entorno de ejecución de Mono que desenreda este marco no lo sabe.

Una variación de esto es iniciar una excepción administrada en código administrado y, a continuación, desenredar a través de fotogramas nativos para llegar a la primera cláusula administrada catch :

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 administrado UIApplication:Main llamará al método nativo UIApplicationMain, y luego iOS ejecutará mucho código nativo antes de eventualmente llamar al método administrado AppDelegate:FinishedLaunching, con todavía 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: TestApp                 mono_jit_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>, error=<unavailable>)
 3: TestApp                 do_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>, error=<unavailable>)
 4: TestApp                 mono_runtime_invoke [inlined] mono_runtime_invoke_checked(method=<unavailable>, obj=<unavailable>, params=<unavailable>, error=0xbff45758)
 5: TestApp                 mono_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>)
 6: TestApp                 xamarin_invoke_trampoline(type=<unavailable>, self=<unavailable>, sel="application:didFinishLaunchingWithOptions:", iterator=<unavailable>), context=<unavailable>)
 7: TestApp                 xamarin_arch_trampoline(state=0xbff45ad4)
 8: TestApp                 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 fotogramas 0-1 y 27-30 están gestionados, mientras que todos los fotogramas entre ellos son nativos. Si Mono se desenrolla a través de estos fotogramas, no se ejecutarán Objective-C @catch ni @finally cláusulas.

Importante

Solo el entorno de ejecución de MonoVM admite el desenrollamiento de fotogramas nativos durante el manejo de excepciones gestionadas. El entorno de ejecución de CoreCLR simplemente anulará el proceso al encontrar esta situación (el entorno de ejecución de CoreCLR se usa para aplicaciones macOS, así como cuando NativeAOT está habilitado en cualquier plataforma).

Escenario 2: no se pueden detectar excepciones de Objective-C

En el escenario siguiente, no es posible detectar excepciones de Objective-C mediante controladores administrados catch porque la excepción Objective-C se controló de otra manera:

  1. Se produce una excepción de Objective-C.
  2. El Objective-C tiempo de ejecución recorre la pila (pero no la desenreda), buscando un controlador nativo @catch que pueda controlar la excepción.
  3. El entorno de ejecución de Objective-C busca un @catch controlador, desenreda la pila y comienza a ejecutar el @catch controlador.

Este escenario se encuentra normalmente en .NET para aplicaciones de iOS, ya que en el subproceso principal normalmente hay código similar al siguiente:

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 no controlada de Objective-C y, por tanto, nuestra devolución de llamada, que convierte las excepciones Objective-C en excepciones administradas, nunca se llama.

Esto también es habitual al depurar aplicaciones macOS en una versión anterior de macOS que la más reciente, ya que la inspección de la mayoría de los objetos de interfaz de usuario del depurador intentará capturar propiedades que corresponden a selectores que no existen en la plataforma en ejecución. Al llamar a estos selectores, se producirá un NSInvalidArgumentException ("Selector no reconocido enviado a ..."), lo que finalmente hará que el proceso se bloquee.

En resumen, tener el entorno de ejecución de Objective-C o los fotogramas de desenredado mono que no están programados para controlar pueden provocar comportamientos indefinidos, como bloqueos, fugas de memoria y otros tipos de comportamientos impredecibles (incorrectos).

Sugerencia

Para las aplicaciones de macOS y Mac Catalyst (pero no para iOS ni tvOS), es posible que el ciclo de la interfaz de usuario no capture todas las excepciones al establecer el valor de la propiedad NSApplicationCrashOnExceptions de la aplicación a true:

var defs = new NSDictionary ((NSString) "NSApplicationCrashOnExceptions", NSValue.FromBoolean (true));
NSUserDefaults.StandardUserDefaults.RegisterDefaults (defs);

Sin embargo, tenga en cuenta que Apple no documenta esta propiedad, por lo que el comportamiento puede cambiar en el futuro.

Solución

Contamos con soporte tanto para detectar excepciones administradas como Objective-C en cualquier límite entre nativo y administrado, y para convertir esa excepción al otro tipo.

En pseudocódigo, tiene un aspecto similar al siguiente:

class MyClass {
    [DllImport (Constants.ObjectiveCLibrary)]
    static extern void objc_msgSend (IntPtr handle, IntPtr selector);

    static void DoSomething (NSObject obj)
    {
        objc_msgSend (obj.Handle, Selector.GetHandle ("doSomething"));
    }
}

Se intercepta la instrucción P/Invoke a objc_msgSend 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 se realiza algo similar para el caso inverso (manejo de excepciones administradas hacia excepciones Objective-C).

En .NET, el manejo de excepciones administradas a excepciones del tipo Objective-C está habilitado por defecto.

En la sección indicadores de tiempo de compilación se explica cómo deshabilitar la interceptación cuando es el valor por defecto.

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 ExceptionMode propiedad se puede cambiar en el controlador de eventos para cambiar el comportamiento según 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 ExceptionMode propiedad se aplica al evento único, no afecta a ninguna excepción interceptada en el futuro.

Los siguientes modos están disponibles al transferir excepciones administradas a código nativo:

  • Default: Actualmente, siempre es ThrowObjectiveCException. El valor predeterminado puede cambiar en el futuro.
  • UnwindNativeCode: Esto no está disponible cuando se utiliza CoreCLR (CoreCLR no soporta el desenrollado de código nativo, en su lugar, abortará el proceso).
  • ThrowObjectiveCException: Convertir la excepción administrada en una excepción de Objective-C y lanzar la excepción Objective-C. Este es el valor predeterminado en .NET.
  • Abort: anula el proceso.
  • Disable: deshabilita la interceptación de excepciones. No tiene sentido establecer este valor en el controlador de eventos (una vez que se genera el evento, es demasiado tarde para deshabilitar la interceptación de la excepción). En cualquier caso, si se establece, se comportará como UnwindNativeCode.

Los siguientes modos están disponibles al gestionar las excepciones Objective-C hacia el código gestionado:

  • Default: Actualmente, siempre está en ThrowManagedException dentro de .NET. El valor predeterminado puede cambiar en el futuro.
  • UnwindManagedCode: este es el comportamiento anterior (indefinido).
  • ThrowManagedException: Convierta la excepción de Objective-C en una excepción administrada y lance la excepción administrada. Este es el valor predeterminado en .NET.
  • Abort: anula el proceso.
  • Disable: deshabilita la interceptación de excepciones. No tiene sentido establecer este valor en el controlador de eventos (una vez que se genera el evento, es demasiado tarde para deshabilitar la interceptación de la excepción). En cualquier caso, si se establece, se comportará como UnwindManagedCode.

Por lo tanto, para ver cada vez que se canaliza una excepción, puede hacerlo:

class MyApp {
    static void Main (string args[])
    {
        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);
        };
        /// ...
    }
}

Sugerencia

Lo ideal es que las excepciones Objective-C no se produzcan en una aplicación bien comportada (Apple las considera mucho más excepcionales que las excepciones administradas: "evite iniciar excepciones [Objective-C] en una aplicación que envíe a los usuarios". Una manera de lograr esto sería agregar un controlador de eventos para el evento Runtime.MarshalObjectiveCException que registre todas las excepciones marcadas de Objective-C mediante telemetría (para configuraciones locales o de depuración, también puede establecer el modo de excepción en "Abortar") para detectar todas estas excepciones con el fin de corregirlas o evitarlas.

Banderas de Build-Time

Es posible establecer las siguientes propiedades de MSBuild, que determinarán si la interceptación de excepciones está habilitada y establecer la acción predeterminada que debe producirse:

  • MarshalManagedExceptionMode: "default", "unwindnativecode", "throwobjectivecexception", "abort", "disable".
  • MarshalObjectiveCExceptionMode: "default", "unwindmanagedcode", "throwmanagedexception", "abort", "disable".

Ejemplo:

<PropertyGroup>
    <MarshalManagedExceptionMode>throwobjectivecexception</MarshalManagedExceptionMode>
    <MarshalObjectiveCExceptionMode>throwmanagedexception</MarshalObjectiveCExceptionMode>
</PropertyGroup>

Excepto para disable, estos valores son idénticos a los valores que se pasan a los ExceptionMode eventos MarshalManagedException y MarshalObjectiveCException .

La disable opción deshabilitará principalmente la interceptación, salvo que se interceptarán las excepciones cuando no agregue ninguna sobrecarga de ejecución. Los eventos de agregación se siguen generando para estas excepciones, y el modo predeterminado es el modo predeterminado para la plataforma en ejecución.

Limitaciones

Solo interceptamos P/Invokes al conjunto de funciones objc_msgSend al intentar detectar excepciones Objective-C. Esto significa que un P/Invoke a otra función en C, que después lanza cualquier excepción Objective-C, seguirá encontrándose con el comportamiento antiguo e indefinido (esto puede mejorarse en el futuro).

Consulte también