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
:
- Une Objective-C exception est levée.
- 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. - Le Objective-C runtime ne trouve aucun
@catch
gestionnaire, appelleNSGetUncaughtExceptionHandler
et appelle le gestionnaire installé par Xamarin.iOS/Xamarin.Mac. - 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 :
- Une Objective-C exception est levée.
- 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. - 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 toujoursThrowObjectiveCException
dans .NET. Pour les projets Xamarin hérités, c’estThrowObjectiveCException
si le GC est en mode coopératif (watchOS), etUnwindNativeCode
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 commeUnwindNativeCode
.
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 toujoursThrowManagedException
dans .NET. Pour les projets Xamarin hérités, c’estThrowManagedException
si le GC est en mode coopératif (watchOS), etUnwindManagedCode
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).