Marshaling des exceptions dans Xamarin.iOS et Xamarin.Mac

Le code managé et Objective-C la prise en charge des exceptions d’exécution (clauses try/catch/finally).

Toutefois, leurs implémentations sont différentes, ce qui signifie que les bibliothèques runtime (le runtime Mono ou CoreCLR et les Objective-C bibliothèques runtime) rencontrent des problèmes lorsqu’elles doivent gérer des exceptions, puis exécuter du code écrit dans d’autres langages.

Ce document explique les problèmes qui peuvent se produire et les solutions possibles.

Il inclut également un exemple de projet, Exception Marshaling, qui peut être utilisé pour tester différents scénarios et leurs solutions.

Problème

Le problème se produit lorsqu’une exception est levée et qu’au cours du déroulement de la pile, un frame qui ne correspond pas au type d’exception qui a été levée est rencontré.

Un exemple classique de ce problème est lorsqu’une API native lève une Objective-C exception, puis que cette Objective-C exception doit en quelque sorte être gérée lorsque le processus de déroulement de la pile atteint une trame managée.

Pour les projets Xamarin hérités (pre-.NET), l’action par défaut consiste à ne rien faire. Pour l’exemple ci-dessus, cela signifie laisser le runtime dérouler les Objective-C trames managées. Cette action est problématique, car le Objective-C runtime ne sait pas comment dérouler les trames managées ; par exemple, il n’exécute aucune catch clause ou finally dans ce frame.

Code rompu

Considérez l’exemple de code suivant :

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

Ce code lève une Objective-C exception NSInvalidArgumentException dans le code natif :

NSInvalidArgumentException *** setObjectForKey: key cannot be nil

Et la trace de pile sera semblable à ceci :

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 ()

Les trames 0 à 3 sont des images natives, et le déroulement de la pile dans le Objective-C runtime peut les dérouler. En particulier, il exécute des Objective-C@catch clauses ou @finally .

Toutefois, le déroulement de la Objective-C pile n’est pas en mesure de dérouler correctement les trames managées (trames 4 à 6) : le déroulement de la Objective-C pile déroule les trames managées, mais n’exécute aucune logique d’exception managée (par catch exemple, ou les clauses « finally »).

Cela signifie qu’il n’est généralement pas possible d’intercepter ces exceptions de la manière suivante :

try {
    var dict = new NSMutableDictionary ();
    dict.LowLevelSetObject (IntPtr.Zero, IntPtr.Zero);
} catch (Exception ex) {
    Console.WriteLine (ex);
} finally {
    Console.WriteLine ("finally");
}

Cela est dû au fait que le Objective-C déroutant de pile ne connaît pas la clause managée catch et que la finally clause ne sera pas exécutée non plus.

Lorsque l’exemple de code ci-dessus est efficace, c’est parce qu’il Objective-C a une méthode pour être averti des exceptions non gérées, NSSetUncaughtExceptionHandler, que Xamarin.iOS et Xamarin.Mac utilisent, et à ce stade tente de convertir les exceptions en Objective-C exceptions managéesObjective-C.

Scénarios

Scénario 1 : interception d’exceptions Objective-C avec un gestionnaire catch managé

Dans le scénario suivant, il est possible d’intercepter des Objective-C exceptions à l’aide de gestionnaires managés catch :

  1. Une Objective-C exception est levée.
  2. Le Objective-C runtime guide la pile (mais ne la déroule pas), à la recherche d’un gestionnaire natif @catch capable de gérer l’exception.
  3. Le Objective-C runtime ne trouve aucun @catch gestionnaire, appelle NSGetUncaughtExceptionHandleret appelle le gestionnaire installé par Xamarin.iOS/Xamarin.Mac.
  4. Le gestionnaire de Xamarin.iOS/Xamarin.Mac convertit l’exception Objective-C en exception managée et la lève. Étant donné que le Objective-C runtime n’a pas déroulé la pile (seulement l’a parcourue), l’image actuelle est identique à l’endroit où l’exception Objective-C a été levée.

Un autre problème se produit ici, car le runtime Mono ne sait pas comment dérouler Objective-C correctement les trames.

Lorsque le rappel d’exception non interceptée Objective-C de Xamarin.iOS est appelé, la pile est comme suit :

 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]

Ici, les seuls frames managés sont les images 8 à 10, mais l’exception managée est levée dans l’image 0. Cela signifie que le runtime Mono doit dérouler les trames natives 0-7, ce qui provoque un problème équivalent au problème décrit ci-dessus : bien que le runtime Mono déroule les trames natives, il n’exécute aucune Objective-C@catch clause ou @finally .

Exemple de code :

-(id) setObject: (id) object forKey: (id) key
{
    @try {
        if (key == nil)
            [NSException raise: @"NSInvalidArgumentException"];
    } @finally {
        NSLog (@"This won't be executed");
    }
}

Et la @finally clause ne sera pas exécutée, car le runtime Mono qui déroule ce frame ne le sait pas.

Une variante de ceci consiste à lever une exception managée dans le code managé, puis à se dérouler au sein d’images natives pour accéder à la première clause managée 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.");
        }
    }
}

La méthode managée UIApplication:Main appellera la méthode native UIApplicationMain , puis iOS exécutera beaucoup de code natif avant d’appeler la méthode managée AppDelegate:FinishedLaunching , avec encore de nombreux frames natifs sur la pile lorsque l’exception managée est levée :

 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[])

Les trames 0-1 et 27-30 sont gérées, tandis que toutes les trames intermédiaires sont natives. Si Mono se déroule à travers ces trames, aucune clause ou ne Objective-C@catch@finally sera exécutée.

Scénario 2 : impossible d’intercepter Objective-C les exceptions

Dans le scénario suivant, il n’est pas possible d’intercepter Objective-C les exceptions à l’aide de gestionnaires managés catch , car l’exception Objective-C a été gérée d’une autre manière :

  1. Une Objective-C exception est levée.
  2. Le Objective-C runtime guide la pile (mais ne la déroule pas), à la recherche d’un gestionnaire natif @catch capable de gérer l’exception.
  3. Le Objective-C runtime recherche un @catch gestionnaire, déroule la pile et commence à exécuter le @catch gestionnaire.

Ce scénario se trouve généralement dans les applications Xamarin.iOS, car sur le thread main, il y a généralement du code comme suit :

void UIApplicationMain ()
{
    @try {
        while (true) {
            ExecuteRunLoop ();
        }
    } @catch (NSException *ex) {
        NSLog (@"An unhandled exception occured: %@", exc);
        abort ();
    }
}

Cela signifie que sur le thread main, il n’y a jamais vraiment d’exception non gérée, et donc notre rappel qui convertit des exceptions en exceptions managées Objective-C n’est jamais Objective-C appelé.

Cela est également courant lors du débogage d’applications Xamarin.Mac sur une version antérieure de macOS prise en charge par Xamarin.Mac, car l’inspection de la plupart des objets d’interface utilisateur dans le débogueur tente d’extraire les propriétés qui correspondent aux sélecteurs qui n’existent pas sur la plateforme en cours d’exécution (car Xamarin.Mac prend en charge une version macOS supérieure). L’appel de ces sélecteurs lève un NSInvalidArgumentException (« Sélecteur non reconnu envoyé à ... »), ce qui finit par provoquer le blocage du processus.

Pour résumer, le fait d’avoir des Objective-C trames de déroulement du runtime ou du runtime Mono qu’ils ne sont pas programmés pour gérer peut entraîner des comportements non définis, tels que des incidents, des fuites de mémoire et d’autres types de (erreurs)comportements imprévisibles.

Solution

Dans Xamarin.iOS 10 et Xamarin.Mac 2.10, nous avons ajouté la prise en charge de l’interception des exceptions managées et Objective-C des exceptions sur n’importe quelle limite managée native, et de la conversion de cette exception en un autre type.

Dans le pseudo-code, il ressemble à ceci :

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

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

Le P/Invoke pour objc_msgSend est intercepté, et ce code est appelé à la place :

void
xamarin_dyn_objc_msgSend (id obj, SEL sel)
{
    @try {
        objc_msgSend (obj, sel);
    } @catch (NSException *ex) {
        convert_to_and_throw_managed_exception (ex);
    }
}

Et quelque chose de similaire est fait pour la casse inverse (marshaling des exceptions managées aux Objective-C exceptions).

L’interception d’exceptions sur la limite native managée n’étant pas gratuite, pour les projets Xamarin hérités (pre-.NET), elle n’est pas toujours activée par défaut :

  • Xamarin.iOS/tvOS : l’interception des Objective-C exceptions est activée dans le simulateur.
  • Xamarin.watchOS : l’interception est appliquée dans tous les cas, car le fait de laisser le Objective-C runtime dérouler les trames managées va perturber le récupérateur de mémoire et le faire se bloquer ou se bloquer.
  • Xamarin.Mac : l’interception des Objective-C exceptions est activée pour les builds de débogage.

Dans .NET, le marshaling des exceptions managées en Objective-C exceptions est toujours activé par défaut.

La section Indicateurs de build explique comment activer l’interception lorsqu’elle n’est pas activée par défaut (ou désactiver l’interception quand elle est la valeur par défaut).

Événements

Deux événements sont déclenchés une fois qu’une exception est interceptée : Runtime.MarshalManagedException et Runtime.MarshalObjectiveCException.

Les deux événements reçoivent un EventArgs objet qui contient l’exception d’origine qui a été levée (la Exception propriété) et une ExceptionMode propriété pour définir la façon dont l’exception doit être marshalée.

La ExceptionMode propriété peut être modifiée dans le gestionnaire d’événements pour modifier le comportement en fonction de tout traitement personnalisé effectué dans le gestionnaire. Un exemple serait d’abandonner le processus si une certaine exception se produit.

La modification de la ExceptionMode propriété s’applique à l’événement unique. Elle n’affecte aucune exception interceptée ultérieurement.

Les modes suivants sont disponibles lors du marshaling d’exceptions managées dans du code natif :

  • Default: la valeur par défaut varie selon la plateforme. Il est toujours ThrowObjectiveCException dans .NET. Pour les projets Xamarin hérités, c’est ThrowObjectiveCException si le GC est en mode coopératif (watchOS), et UnwindNativeCode sinon (iOS / watchOS / macOS). La valeur par défaut peut changer à l’avenir.
  • UnwindNativeCode: il s’agit du comportement précédent (non défini). Cela n’est pas disponible lors de l’utilisation du gc en mode coopératif (qui est la seule option sur watchOS ; par conséquent, il ne s’agit pas d’une option valide sur watchOS), ni lors de l’utilisation de CoreCLR, mais c’est l’option par défaut pour toutes les autres plateformes dans les projets Xamarin hérités.
  • ThrowObjectiveCException: convertissez l’exception managée en une Objective-C exception et lèvez l’exception Objective-C . Il s’agit de la valeur par défaut dans .NET et sur watchOS dans les projets Xamarin hérités.
  • Abort: Abandonnez le processus.
  • Disable: désactive l’interception des exceptions, il n’est donc pas judicieux de définir cette valeur dans le gestionnaire d’événements, mais une fois l’événement déclenché, il est trop tard pour le désactiver. Dans tous les cas, s’il est défini, il se comporte comme UnwindNativeCode.

Les modes suivants sont disponibles lors du marshaling d’exceptions Objective-C dans du code managé :

  • Default: la valeur par défaut varie selon la plateforme. Il est toujours ThrowManagedException dans .NET. Pour les projets Xamarin hérités, c’est ThrowManagedException si le GC est en mode coopératif (watchOS), et UnwindManagedCode sinon (iOS / tvOS / macOS). La valeur par défaut peut changer à l’avenir.
  • UnwindManagedCode: il s’agit du comportement précédent (non défini). Cette option n’est pas disponible lors de l’utilisation du gc en mode coopératif (qui est le seul mode GC valide sur watchOS ; il ne s’agit donc pas d’une option valide sur watchOS), ni lors de l’utilisation de CoreCLR, mais il s’agit de la valeur par défaut pour toutes les autres plateformes dans les projets Xamarin hérités.
  • ThrowManagedException: convertissez l’exception Objective-C en exception managée et lèvez l’exception managée. Il s’agit de la valeur par défaut dans .NET et sur watchOS dans les projets Xamarin hérités.
  • Abort: Abandonnez le processus.
  • Disable: désactive l’interception des exceptions. Il n’est donc pas judicieux de définir cette valeur dans le gestionnaire d’événements, mais une fois l’événement déclenché, il est trop tard pour le désactiver. Dans tous les cas, s’il est défini, il abandonne le processus.

Ainsi, pour voir chaque fois qu’une exception est marshalée, vous pouvez procéder comme suit :

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);
};

indicateurs de Build-Time

Il est possible de passer les options suivantes à mtouch (pour les applications Xamarin.iOS) et mmp (pour les applications Xamarin.Mac), qui détermine si l’interception des exceptions est activée et définit l’action par défaut qui doit se produire :

  • --marshal-managed-exceptions=

    • default
    • unwindnativecode
    • throwobjectivecexception
    • abort
    • disable
  • --marshal-objectivec-exceptions=

    • default
    • unwindmanagedcode
    • throwmanagedexception
    • abort
    • disable

À l’exception de disable, ces valeurs sont identiques aux valeurs passées aux MarshalManagedException événements et MarshalObjectiveCException .ExceptionMode

L’option disable désactive principalement l’interception, mais nous interceptons toujours les exceptions quand elle n’ajoute aucune surcharge d’exécution. Les événements de marshaling sont toujours déclenchés pour ces exceptions, le mode par défaut étant le mode par défaut pour la plateforme en cours d’exécution.

Limites

Nous interceptons uniquement P/Invokes dans la objc_msgSend famille de fonctions lorsque vous essayez d’intercepter des Objective-C exceptions. Cela signifie qu’un P/Invoke vers une autre fonction C, qui lève ensuite des Objective-C exceptions, se heurtera toujours au comportement ancien et non défini (cela peut être amélioré à l’avenir).