Partager via


Gestion des exceptions

Le code managé et Objective-C prennent en charge les exceptions d'exécution (clauses try/catch/finally).

Toutefois, leurs implémentations sont différentes, ce qui signifie que les bibliothèques d’exécution (runtimes MonoVM/CoreCLR et les bibliothèques d’exécution Objective-C) présentent des problèmes lorsqu’elles rencontrent des exceptions à partir d’autres runtimes.

Cet article 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 survient lorsqu'une exception est levée, et qu'au cours du déroulement de la pile, on rencontre une trame qui ne correspond pas au type d'exception levée.

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

Dans le passé (pre-.NET), l’action par défaut était de ne rien faire. Pour l’exemple ci-dessus, cela signifie que le runtime Objective-C déroule les trames gérées. Cette action est problématique, car le runtime Objective-C ne parvient pas à dérouler les trames managées ; par exemple, il n’exécute pas de clauses catch ou finally managées, ce qui entraîne des bogues extrêmement difficiles à trouver.

Code rompu

Considérez l’exemple de code suivant :

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

Ce code va lancer une exception NSInvalidArgumentException Objective-C dans le code natif :

NSInvalidArgumentException *** setObjectForKey: key cannot be nil

Et la trace de pile sera quelque chose comme 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 images 0-3 sont des images natives, et le déroulement de la pile dans le runtime Objective-C peut décompresser ces images. En particulier, il exécutera toutes les clauses Objective-C @catch ou @finally.

Toutefois, le déroulement de la pile Objective-C n’est pas capable de déroulier correctement les trames managées (images 4-6) : le déroulement de la pile Objective-C déroulant les images managées, mais n’exécute pas de logique d’exception managée (par exemple catch , ou finally des clauses).

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 dérouleur de pile Objective-C ne connaît pas la clause managée catch, et que la clause finally ne sera pas exécutée.

Lorsque l’exemple de code ci-dessus est efficace, c’est parce que Objective-C a une méthode d’être avertie d’exceptions non gérées Objective-C, NSSetUncaughtExceptionHandlerque les kits SDK .NET utilisent, et à ce stade tente de convertir les exceptions Objective-C en exceptions gérées.

Scénarios

Scénario 1 : intercepter des exceptions Objective-C avec un gestionnaire d'interception géré

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

  1. Une exception Objective-C est levée.
  2. Le runtime Objective-C parcourt la pile (mais ne la déroule pas), en recherchant un gestionnaire natif @catch pour gérer l'exception.
  3. Le runtime Objective-C ne trouve pas de @catch gestionnaires, appelle NSGetUncaughtExceptionHandler et invoque le gestionnaire installé par le SDK .NET.
  4. Le gestionnaire des kits de développement logiciel (SDK) .NET convertit l’exception Objective-C en exception managée, puis la lève. Étant donné que le runtime Objective-C n’a pas déroulé la pile (uniquement l’a parcourue), l’image actuelle est identique à celle 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éroulage Objective-C images correctement.

Lorsque le rappel d’exception des kits sdk .NET n’a pas été lancé Objective-C est appelé, la pile est semblable à ceci :

 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]

Ici, les seules trames managées sont les trames 8 à 10, mais l’exception managée est levée dans la trame 0. Cela signifie que le runtime Mono doit dérouler les cadres natifs 0-7, ce qui provoque un problème équivalent au problème décrit ci-dessus : bien que le runtime Mono déroule les cadres natifs, il n’exécute aucune des clauses Objective-C @catch 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éroulait cette trame ne le sait pas.

Une variante de ceci consiste à lever une exception managée dans le code managé, puis à décompresser les trames 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 appelle la méthode native UIApplicationMain , puis iOS effectue un grand nombre d’exécutions de code natif avant d’appeler la méthode managée AppDelegate:FinishedLaunching , avec encore de nombreuses images natives 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: 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[])

Les images 0-1 et 27-30 sont gérées, tandis que toutes les images entre elles sont natives. Si Mono déroule ces cadres, aucunes clauses Objective-C @catch ou @finally ne sont exécutées.

Important

Seul le runtime MonoVM prend en charge le déroulement des images natives pendant la gestion des exceptions gérées. Le runtime CoreCLR abandonne simplement le processus lors de cette situation (le runtime CoreCLR est utilisé pour les applications macOS, ainsi que lorsque NativeAOT est activé sur n’importe quelle plateforme).

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

Dans le scénario suivant, il n’est pas possible d’intercepter Objective-C 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 exception Objective-C est levée.
  2. Le runtime Objective-C parcourt la pile (mais ne la déroule pas), afin de trouver un gestionnaire natif @catch capable de gérer l'exception.
  3. Le runtime Objective-C recherche un @catch gestionnaire, décompresse la pile et commence à exécuter le @catch gestionnaire.

Ce scénario est généralement trouvé dans .NET pour les applications iOS, car sur le thread principal, il existe 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 principal, il n’y a jamais vraiment d’exception Objective-C non gérée, et par conséquent, notre rappel qui convertit Objective-C exceptions en exceptions managées n’est jamais appelé.

Cela est également courant lors du débogage d’applications macOS sur une version antérieure de macOS, 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. L’appel de ces sélecteurs lève un NSInvalidArgumentException « sélecteur non reconnu envoyé à ... », ce qui entraîne finalement le blocage du processus.

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

Conseil / Astuce

Pour les applications macOS et Mac Catalyst (mais pas iOS ou tvOS), il est possible de configurer la boucle d'interface utilisateur pour qu'elle ne capture pas toutes les exceptions, en définissant la propriété NSApplicationCrashOnExceptions de l'application sur true:

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

Toutefois, notez que cette propriété n’est pas documentée par Apple. Par conséquent, le comportement peut changer à l’avenir.

Solution

Nous prenons en charge l'interception des exceptions gérées et Objective-C sur n'importe quelle interface managée-native, afin de convertir cette exception en un autre type.

Dans le pseudo-code, il ressemble à ceci :

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

L'appel à P/Invoke objc_msgSend est intercepté, et ce code est exécuté à 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 le cas inverse (transfert des exceptions managées vers les exceptions Objective-C).

Dans .NET, le marshaling des exceptions gérées pour Objective-C exceptions est toujours activé par défaut.

La section Indicateurs de génération explique comment désactiver l’interception lorsqu’il s’agit de la valeur par défaut.

Événements

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

Les deux événements sont passés à un EventArgs objet qui contient l’exception d’origine levée (propriété Exception ) 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 du traitement personnalisé effectué dans le gestionnaire. Un exemple consisterait à 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 vers du code natif :

  • Default: Actuellement, c’est toujours ThrowObjectiveCException. La valeur par défaut peut changer à l’avenir.
  • UnwindNativeCode: Cela n’est pas disponible lors de l’utilisation de CoreCLR (CoreCLR ne prend pas en charge le déroulement du code natif, donc il interrompt le processus).
  • ThrowObjectiveCException: convertissez l’exception managée en exception Objective-C et lèvez l’exception Objective-C. Il s’agit de la valeur par défaut dans .NET.
  • Abort: abandonner le processus.
  • Disable: désactive l’interception des exceptions. Il n’est pas judicieux de définir cette valeur dans le gestionnaire d’événements (une fois que l’événement est déclenché, il est trop tard pour désactiver l’interception de l’exception). Dans tous les cas, s’il est défini, il se comporte comme UnwindNativeCode.

Les modes suivants sont disponibles pour le transfert des exceptions Objective-C vers le code géré :

  • Default: Actuellement, c'est toujours ThrowManagedException dans .NET. La valeur par défaut peut changer à l’avenir.
  • UnwindManagedCode: Il s’agit du comportement précédent (non défini).
  • 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.
  • Abort: abandonner le processus.
  • Disable: désactive l’interception des exceptions. Il n’est pas judicieux de définir cette valeur dans le gestionnaire d’événements (une fois que l’événement est déclenché, il est trop tard pour désactiver l’interception de l’exception). Dans tous les cas, s’il est défini, il se comporte comme UnwindManagedCode.

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

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

Conseil / Astuce

Dans l’idéal, les Objective-C exceptions ne doivent pas se produire dans une application bien conçue (Apple les considère comme bien plus exceptionnelles que les exceptions gérées : « évitez de lever des exceptions [Objective-C] dans une application que vous distribuez aux utilisateurs »). Une façon d’y parvenir consisterait à ajouter un gestionnaire d’événements pour l’événement Runtime.MarshalObjectiveCException qui journaliserait toutes les exceptions marshalées Objective-C à l’aide de la télémétrie (pour les builds de débogage/locales peut également définir le mode d’exception sur « Abort » ainsi) pour détecter toutes ces exceptions afin de les corriger/éviter.

Drapeaux de Build-Time

Il est possible de définir les propriétés MSBuild suivantes, qui déterminent si l’interception des exceptions est activée et définissez l’action par défaut qui doit se produire :

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

Exemple:

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

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

L’option disable désactive principalement l’interception, sauf que nous interceptons toujours les exceptions lorsqu’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 de la plateforme en cours d’exécution.

Limites

Nous interceptons uniquement les appels P/Invoke de la famille objc_msgSend de fonctions dans le cadre de la capture des exceptions Objective-C. Cela signifie qu’une fonction P/Invoke vers une autre fonction C, qui lève ensuite n'importe quelle exception Objective-C, rencontrera toujours le comportement ancien et indéfini (cela peut être amélioré à l’avenir).

Voir aussi