Marshalling delle eccezioni in Xamarin.iOS e Xamarin.Mac

Sia il codice gestito che Objective-C il supporto per le eccezioni di runtime (clausole try/catch/finally).

Tuttavia, le implementazioni sono diverse, il che significa che le librerie di runtime (il runtime Mono o CoreCLR e le Objective-C librerie di runtime) presentano problemi quando devono gestire le eccezioni e quindi eseguire il codice scritto in altri linguaggi.

Questo documento illustra i problemi che possono verificarsi e le possibili soluzioni.

Include anche un progetto di esempio, il marshalling delle eccezioni, che può essere usato per testare diversi scenari e le relative soluzioni.

Problema

Il problema si verifica quando viene generata un'eccezione e durante la rimozione dello stack viene rilevato un frame che non corrisponde al tipo di eccezione generata.

Un esempio tipico di questo problema è quando un'API nativa genera un'eccezione Objective-C e quindi tale Objective-C eccezione deve essere gestita in qualche modo quando il processo di rimozione dello stack raggiunge un frame gestito.

Per i progetti Xamarin legacy (pre-.NET), l'azione predefinita non prevede alcuna operazione. Per l'esempio precedente, ciò significa consentire al runtime di rimuovere i Objective-C fotogrammi gestiti. Questa azione è problematica perché il Objective-C runtime non sa come rimuovere i frame gestiti, ad esempio non eseguirà catch alcuna clausola o finally in tale frame.

Codice interrotto

Osservare l'esempio di codice seguente:

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

Questo codice genererà un'eccezione Objective-C NSInvalidArgumentException nel codice nativo:

NSInvalidArgumentException *** setObjectForKey: key cannot be nil

E l'analisi dello stack sarà simile alla seguente:

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

I frame 0-3 sono frame nativi e lo stack di rimozione nel Objective-C runtime può rimuovere tali fotogrammi. In particolare, eseguirà qualsiasi Objective-C@catch clausola o @finally .

Tuttavia, lo Objective-C stack di rimozione non è in grado di rimuovere correttamente i fotogrammi gestiti (frame 4-6): lo Objective-C stack di rimozione rimuoverà i fotogrammi gestiti, ma non eseguirà alcuna logica di eccezione gestita (ad esempio catch o "clausole finally").

Ciò significa che in genere non è possibile intercettare queste eccezioni nel modo seguente:

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

Ciò è dovuto al fatto che lo Objective-C stack di rimozione non conosce la clausola gestita catch e non verrà eseguita nessuna delle finally due clausole.

Quando l'esempio di codice precedente è efficace, è perché Objective-C ha un metodo per ricevere una notifica di eccezioni non Objective-C gestite, , NSSetUncaughtExceptionHandlerche Xamarin.iOS e Xamarin.Mac usano e a questo punto tenta di convertire eventuali Objective-C eccezioni in eccezioni gestite.

Scenari

Scenario 1: rilevamento di Objective-C eccezioni con un gestore catch gestito

Nello scenario seguente è possibile intercettare Objective-C le eccezioni usando gestori gestiti catch :

  1. Viene generata un'eccezione Objective-C .
  2. Il Objective-C runtime illustra lo stack (ma non lo rimuove), cercando un gestore nativo @catch in grado di gestire l'eccezione.
  3. Il Objective-C runtime non trova @catch gestori, chiama NSGetUncaughtExceptionHandlere richiama il gestore installato da Xamarin.iOS/Xamarin.Mac.
  4. Il gestore di Xamarin.iOS/Xamarin.Mac convertirà l'eccezione in un'eccezione Objective-C gestita e la genererà. Poiché il Objective-C runtime non ha rimozione dello stack (è stato eseguito solo il passaggio), il frame corrente è identico a quello in cui è stata generata l'eccezione Objective-C .

In questo caso si verifica un altro problema, perché il runtime di Mono non sa come rimuovere Objective-C correttamente i frame.

Quando viene chiamato il callback delle eccezioni non rilevate Objective-C di Xamarin.iOS, lo stack è simile al seguente:

 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]

In questo caso, gli unici fotogrammi gestiti sono frame 8-10, ma l'eccezione gestita viene generata nel frame 0. Ciò significa che il runtime di Mono deve rimuovere i fotogrammi nativi 0-7, che causa un problema equivalente al problema descritto in precedenza: anche se il runtime mono rimuoverà i fotogrammi nativi, non eseguirà alcuna Objective-C@catch clausola o @finally .

Esempio di codice:

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

E la @finally clausola non verrà eseguita perché il runtime mono che rimuove questo frame non lo conosce.

Una variante di questa operazione consiste nel generare un'eccezione gestita nel codice gestito e quindi rimuovere i fotogrammi nativi per passare alla prima clausola gestita 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.");
        }
    }
}

Il metodo gestito UIApplication:Main chiamerà il metodo nativo UIApplicationMain e quindi iOS eseguirà molte esecuzioni di codice nativo prima di chiamare il metodo gestito AppDelegate:FinishedLaunching , con ancora molti frame nativi nello stack quando viene generata l'eccezione gestita:

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

I fotogrammi da 0 a 1 e 27-30 vengono gestiti, mentre tutti i fotogrammi tra sono nativi. Se Mono si rimuove tramite questi frame, non Objective-C@catch verranno eseguite clausole o @finally .

Scenario 2: non è possibile intercettare Objective-C le eccezioni

Nello scenario seguente non è possibile intercettare Objective-C le eccezioni usando gestori gestiti catch perché l'eccezione Objective-C è stata gestita in un altro modo:

  1. Viene generata un'eccezione Objective-C .
  2. Il Objective-C runtime illustra lo stack (ma non lo rimuove), cercando un gestore nativo @catch in grado di gestire l'eccezione.
  3. Il Objective-C runtime trova un @catch gestore, rimuove lo stack e avvia l'esecuzione del @catch gestore.

Questo scenario si trova comunemente nelle app Xamarin.iOS, perché nel thread principale è in genere presente codice simile al seguente:

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

Ciò significa che nel thread principale non esiste mai un'eccezione non gestita Objective-C e quindi il callback che converte le Objective-C eccezioni in eccezioni gestite non viene mai chiamato.

Ciò è comune anche quando si esegue il debug di app Xamarin.Mac in una versione macOS precedente rispetto a Xamarin.Mac supportata perché l'ispezione della maggior parte degli oggetti dell'interfaccia utente nel debugger tenterà di recuperare le proprietà corrispondenti ai selettori che non esistono nella piattaforma in esecuzione (perché Xamarin.Mac include il supporto per una versione macOS successiva). La chiamata di tali selettori genererà un'eccezione NSInvalidArgumentException ("Selettore non riconosciuto inviato a ..."), che alla fine causa l'arresto anomalo del processo.

Per riepilogare, avere il Objective-C runtime o i frame di rimozione del runtime Mono che non sono programmati per gestire può causare comportamenti non definiti, ad esempio arresti anomali, perdite di memoria e altri tipi di comportamenti imprevedibili (mis).

Soluzione

In Xamarin.iOS 10 e Xamarin.Mac 2.10 è stato aggiunto il supporto per intercettare sia le eccezioni gestite Objective-C che in qualsiasi limite nativo gestito e per convertire tale eccezione nell'altro tipo.

Nello pseudo-codice l'aspetto è simile al seguente:

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

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

Il P/Invoke per objc_msgSend viene intercettato e questo codice viene chiamato invece:

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

E viene fatto qualcosa di simile per il caso inverso (marshalling delle eccezioni gestite alle Objective-C eccezioni).

L'intercettazione delle eccezioni nel limite nativo gestito non è gratuita, quindi per i progetti Xamarin legacy (pre-.NET), non è sempre abilitata per impostazione predefinita:

  • Xamarin.iOS/tvOS: l'intercettazione delle Objective-C eccezioni è abilitata nel simulatore.
  • Xamarin.watchOS: l'intercettazione viene applicata in tutti i casi, perché consentendo al runtime di Objective-C rimuovere i frame gestiti confondere il Garbage Collector e bloccarlo o arrestarsi in modo anomalo.
  • Xamarin.Mac: l'intercettazione delle Objective-C eccezioni è abilitata per le compilazioni di debug.

In .NET il marshalling delle eccezioni gestite alle Objective-C eccezioni è sempre abilitato per impostazione predefinita.

La sezione Flag in fase di compilazione illustra come abilitare l'intercettazione quando non è abilitata per impostazione predefinita (o disabilitare l'intercettazione quando è l'impostazione predefinita).

evento

Dopo l'intercettazione di un'eccezione, vengono generati due eventi: Runtime.MarshalManagedException e Runtime.MarshalObjectiveCException.

Entrambi gli eventi vengono passati a un EventArgs oggetto che contiene l'eccezione originale generata (la Exception proprietà) e una ExceptionMode proprietà per definire la modalità di marshalling dell'eccezione.

La ExceptionMode proprietà può essere modificata nel gestore eventi per modificare il comportamento in base a qualsiasi elaborazione personalizzata eseguita nel gestore. Un esempio consiste nell'interrompere il processo se si verifica una determinata eccezione.

La modifica della ExceptionMode proprietà si applica all'evento singolo e non influisce sulle eccezioni intercettate in futuro.

Quando si effettua il marshalling delle eccezioni gestite nel codice nativo, sono disponibili le modalità seguenti:

  • Default: il valore predefinito varia in base alla piattaforma. È sempre ThrowObjectiveCException in .NET. Per i progetti Xamarin legacy, è ThrowObjectiveCException se GC è in modalità cooperativa (watchOS) e UnwindNativeCode in caso contrario (iOS/watchOS/macOS). Il valore predefinito potrebbe cambiare in futuro.
  • UnwindNativeCode: si tratta del comportamento precedente (non definito). Questa opzione non è disponibile quando si usa GC in modalità cooperativa ,che è l'unica opzione su watchOS, pertanto questa non è un'opzione valida in watchOS, né quando si usa CoreCLR, ma è l'opzione predefinita per tutte le altre piattaforme nei progetti Xamarin legacy.
  • ThrowObjectiveCException: convertire l'eccezione gestita in un'eccezione Objective-C e generare l'eccezione Objective-C . Questa è l'impostazione predefinita in .NET e in watchOS nei progetti Xamarin legacy.
  • Abort: interrompe il processo.
  • Disable: disabilita l'intercettazione dell'eccezione, quindi non ha senso impostare questo valore nel gestore eventi, ma una volta generato l'evento è troppo tardi per disabilitarlo. In ogni caso, se impostato, si comporterà come UnwindNativeCode.

Quando si effettua il marshalling delle Objective-C eccezioni al codice gestito, sono disponibili le modalità seguenti:

  • Default: il valore predefinito varia in base alla piattaforma. È sempre ThrowManagedException in .NET. Per i progetti Xamarin legacy, è ThrowManagedException se GC è in modalità cooperativa (watchOS) e UnwindManagedCode in caso contrario (iOS/tvOS/macOS). Il valore predefinito potrebbe cambiare in futuro.
  • UnwindManagedCode: si tratta del comportamento precedente (non definito). Questa opzione non è disponibile quando si usa GC in modalità cooperativa (che è l'unica modalità GC valida in watchOS, pertanto non è un'opzione valida in watchOS), né quando si usa CoreCLR, ma è l'impostazione predefinita per tutte le altre piattaforme nei progetti Xamarin legacy.
  • ThrowManagedException: convertire l'eccezione in un'eccezione Objective-C gestita e generare l'eccezione gestita. Questa è l'impostazione predefinita in .NET e in watchOS nei progetti Xamarin legacy.
  • Abort: interrompe il processo.
  • Disable: disabilita l'intercettazione dell'eccezione, quindi non ha senso impostare questo valore nel gestore eventi, ma una volta generato l'evento, è troppo tardi per disabilitarlo. In ogni caso, se impostato, interromperà il processo.

Per visualizzare ogni volta che viene eseguito il marshalling di un'eccezione, è possibile eseguire questa operazione:

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

Flag in fase di compilazione

È possibile passare le opzioni seguenti a mtouch (per le app Xamarin.iOS) e mmp (per le app Xamarin.Mac), che determinerà se è abilitata l'intercettazione delle eccezioni e imposta l'azione predefinita che deve verificarsi:

  • --marshal-managed-exceptions=

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

    • default
    • unwindmanagedcode
    • throwmanagedexception
    • abort
    • disable

Ad eccezione di disable, questi valori sono identici ai ExceptionMode valori passati agli MarshalManagedException eventi e MarshalObjectiveCException .

L'opzione disable disabiliterà principalmente l'intercettazione, ad eccezione dei casi in cui non verrà aggiunto alcun sovraccarico di esecuzione. Gli eventi di marshalling vengono comunque generati per queste eccezioni, con la modalità predefinita come modalità predefinita per la piattaforma in esecuzione.

Limiti

I P/Invoke vengono intercettati solo nella objc_msgSend famiglia di funzioni quando si tenta di intercettare Objective-C le eccezioni. Ciò significa che un P/Invoke in un'altra funzione C, che genera quindi eventuali Objective-C eccezioni, continuerà a verificarsi nel comportamento precedente e non definito (questo potrebbe essere migliorato in futuro).