Marshaling de exceção no Xamarin.iOS e Xamarin.Mac

O código gerenciado e Objective-C têm suporte para exceções de runtime (cláusulas try/catch/finally).

No entanto, suas implementações são diferentes, o que significa que as bibliotecas de runtime (o runtime Mono ou CoreCLR e as Objective-C bibliotecas de runtime) têm problemas quando precisam lidar com exceções e, em seguida, executar código escrito em outras linguagens.

Este documento explica os problemas que podem ocorrer e as possíveis soluções.

Ele também inclui um projeto de exemplo, o Marshaling de Exceções, que pode ser usado para testar diferentes cenários e suas soluções.

Problema

O problema ocorre quando uma exceção é gerada e, durante o desenrolamento da pilha, um quadro é encontrado, o que não corresponde ao tipo de exceção que foi gerada.

Um exemplo típico desse problema é quando uma API nativa gera uma exceção Objective-C e, em seguida, essa Objective-C exceção deve de alguma forma ser tratada quando o processo de desenrolamento de pilha atinge um quadro gerenciado.

Para projetos herdados do Xamarin (pre-.NET), a ação padrão é não fazer nada. Para o exemplo acima, isso significa deixar o Objective-C runtime desenrolar quadros gerenciados. Essa ação é problemática, pois o Objective-C runtime não sabe como desenrolar quadros gerenciados; por exemplo, não executará nenhuma catch cláusula ou finally nesse quadro.

Código quebrado

Considere o seguinte exemplo de código:

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

Esse código gerará uma Objective-C NSInvalidArgumentException no código nativo:

NSInvalidArgumentException *** setObjectForKey: key cannot be nil

E o rastreamento de pilha será algo assim:

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

Os quadros 0 a 3 são quadros nativos e o desenrolador de pilha no Objective-C runtime pode desenrolar esses quadros. Em particular, ele executará qualquer Objective-C@catch cláusula ou @finally .

No entanto, o Objective-C desenrolador de pilha não é capaz de desenrolar corretamente os quadros gerenciados (quadros 4-6): o Objective-C desenrolador de pilha desenrolará os quadros gerenciados, mas não executará nenhuma lógica de exceção gerenciada (como catch ou 'finally clauses).

O que significa que geralmente não é possível capturar essas exceções da seguinte maneira:

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

Isso ocorre porque o Objective-C desenrolador de pilha não sabe sobre a cláusula gerenciada catch e nem a finally cláusula será executada.

Quando o exemplo de código acima é eficaz, é porque Objective-C tem um método de ser notificado de exceções sem tratamento Objective-C , NSSetUncaughtExceptionHandler, que o Xamarin.iOS e o Xamarin.Mac usam e, nesse ponto, tenta converter exceções Objective-C em exceções gerenciadas.

Cenários

Cenário 1 – capturando Objective-C exceções com um manipulador catch gerenciado

No cenário a seguir, é possível capturar Objective-C exceções usando manipuladores gerenciados catch :

  1. Uma Objective-C exceção é gerada.
  2. O Objective-C runtime orienta a pilha (mas não a desenrola), procurando um manipulador nativo @catch que possa lidar com a exceção.
  3. O Objective-C runtime não encontra manipuladores @catch , chama NSGetUncaughtExceptionHandlere invoca o manipulador instalado pelo Xamarin.iOS/Xamarin.Mac.
  4. O manipulador do Xamarin.iOS/Xamarin.Mac converterá a Objective-C exceção em uma exceção gerenciada e a lançará. Como o Objective-C runtime não desenrola a pilha (apenas a andou), o quadro atual é o mesmo em que a Objective-C exceção foi lançada.

Outro problema ocorre aqui, porque o runtime mono não sabe como desenrolar Objective-C quadros corretamente.

Quando o retorno de chamada de exceção não capturada Objective-C do Xamarin.iOS é chamado, a pilha é assim:

 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]

Aqui, os únicos quadros gerenciados são quadros de 8 a 10, mas a exceção gerenciada é gerada no quadro 0. Isso significa que o runtime mono deve desenrolar os quadros nativos de 0 a 7, o que causa um problema equivalente ao problema discutido acima: embora o runtime Mono descontraa os quadros nativos, ele não executará nenhuma Objective-C@catch cláusula ou @finally .

Exemplo de código:

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

E a @finally cláusula não será executada porque o runtime mono que desenrola esse quadro não sabe sobre ele.

Uma variação disso é lançar uma exceção gerenciada no código gerenciado e, em seguida, desenrolar por meio de quadros nativos para chegar à primeira cláusula gerenciada 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.");
        }
    }
}

O método gerenciado UIApplication:Main chamará o método nativo UIApplicationMain e, em seguida, o iOS fará muita execução de código nativo antes de eventualmente chamar o método gerenciado AppDelegate:FinishedLaunching , com ainda muitos quadros nativos na pilha quando a exceção gerenciada for gerada:

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

Os quadros 0-1 e 27-30 são gerenciados, enquanto todos os quadros entre eles são nativos. Se o Mono desenrolar esses quadros, nenhuma Objective-C@catch cláusula ou @finally será executada.

Cenário 2 – não é possível capturar Objective-C exceções

No cenário a seguir, não é possível capturar Objective-C exceções usando manipuladores gerenciados catch porque a Objective-C exceção foi tratada de outra maneira:

  1. Uma Objective-C exceção é gerada.
  2. O Objective-C runtime orienta a pilha (mas não a desenrola), procurando um manipulador nativo @catch que possa lidar com a exceção.
  3. O Objective-C runtime localiza um @catch manipulador, desenrola a pilha e começa a executar o @catch manipulador.

Esse cenário geralmente é encontrado em aplicativos Xamarin.iOS, pois no thread main geralmente há um código como este:

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

Isso significa que, no thread main, nunca há realmente uma exceção sem tratamento Objective-C e, portanto, nosso retorno de chamada que converte Objective-C exceções em exceções gerenciadas nunca é chamado.

Isso também é comum ao depurar aplicativos Xamarin.Mac em uma versão anterior do macOS compatível com o Xamarin.Mac porque inspecionar a maioria dos objetos de interface do usuário no depurador tentará buscar propriedades que correspondam a seletores que não existem na plataforma em execução (porque o Xamarin.Mac inclui suporte para uma versão mais alta do macOS). Chamar esses seletores gerará um NSInvalidArgumentException ("Seletor não reconhecido enviado para ..."), o que eventualmente fará com que o processo falhe.

Para resumir, ter o Objective-C runtime ou os quadros de desenrolamento do runtime Mono que eles não estão programados para lidar podem levar a comportamentos indefinidos, como falhas, vazamentos de memória e outros tipos de comportamentos imprevisíveis (incorretos).

Solução

No Xamarin.iOS 10 e no Xamarin.Mac 2.10, adicionamos suporte para capturar exceções e Objective-C gerenciadas em qualquer limite nativo gerenciado e para converter essa exceção para o outro tipo.

No pseudocódigo, ele tem esta aparência:

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

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

O P/Invoke para objc_msgSend é interceptado e esse código é chamado em vez disso:

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 algo semelhante é feito para o caso inverso (realizar marshaling de exceções gerenciadas para Objective-C exceções).

Capturar exceções no limite nativo gerenciado não é gratuito, portanto, para projetos herdados do Xamarin (pre-.NET), ele nem sempre é habilitado por padrão:

  • Xamarin.iOS/tvOS: a interceptação de Objective-C exceções está habilitada no simulador.
  • Xamarin.watchOS: a interceptação é imposta em todos os casos, porque deixar o Objective-C runtime desenrolar quadros gerenciados confundirá o coletor de lixo e o fará travar ou falhar.
  • Xamarin.Mac: a interceptação de Objective-C exceções está habilitada para builds de depuração.

No .NET, o marshaling de exceções gerenciadas para Objective-C exceções é sempre habilitado por padrão.

A seção Sinalizadores de tempo de build explica como habilitar a interceptação quando ela não está habilitada por padrão (ou desabilitar a interceptação quando for o padrão).

Eventos

Há dois eventos que são gerados depois que uma exceção é interceptada: Runtime.MarshalManagedException e Runtime.MarshalObjectiveCException.

Ambos os eventos são passados por um EventArgs objeto que contém a exceção original que foi lançada (a Exception propriedade ) e uma ExceptionMode propriedade para definir como a exceção deve ser empacotada.

A ExceptionMode propriedade pode ser alterada no manipulador de eventos para alterar o comportamento de acordo com qualquer processamento personalizado feito no manipulador. Um exemplo seria anular o processo se ocorrer uma determinada exceção.

A alteração da ExceptionMode propriedade se aplica ao único evento, ela não afeta nenhuma exceção interceptada no futuro.

Os seguintes modos estão disponíveis ao realizar marshaling de exceções gerenciadas para código nativo:

  • Default: o padrão varia de acordo com a plataforma. Ele está sempre ThrowObjectiveCException no .NET. Para projetos herdados do ThrowObjectiveCException Xamarin, é se o GC está no modo cooperativo (watchOS) e UnwindNativeCode , caso contrário, (iOS/watchOS/macOS). O padrão pode ser alterado no futuro.
  • UnwindNativeCode: esse é o comportamento anterior (indefinido). Isso não está disponível ao usar o GC no modo cooperativo (que é a única opção no watchOS; portanto, essa não é uma opção válida no watchOS), nem ao usar o CoreCLR, mas é a opção padrão para todas as outras plataformas em projetos Xamarin herdados.
  • ThrowObjectiveCException: converta a exceção gerenciada em uma exceção Objective-C e gere a Objective-C exceção. Esse é o padrão no .NET e no watchOS em projetos herdados do Xamarin.
  • Abort: anula o processo.
  • Disable: desabilita a interceptação de exceção, portanto, não faz sentido definir esse valor no manipulador de eventos, mas depois que o evento é acionado, é tarde demais para desabilitá-lo. De qualquer forma, se definido, ele se comportará como UnwindNativeCode.

Os seguintes modos estão disponíveis ao realizar marshaling de Objective-C exceções para código gerenciado:

  • Default: o padrão varia de acordo com a plataforma. Ele está sempre ThrowManagedException no .NET. Para projetos herdados do ThrowManagedException Xamarin, é se o GC está no modo cooperativo (watchOS) e UnwindManagedCode , caso contrário, (iOS/tvOS/macOS). O padrão pode ser alterado no futuro.
  • UnwindManagedCode: esse é o comportamento anterior (indefinido). Isso não está disponível ao usar o GC no modo cooperativo (que é o único modo GC válido no watchOS; portanto, essa não é uma opção válida no watchOS), nem ao usar CoreCLR, mas é o padrão para todas as outras plataformas em projetos Xamarin herdados.
  • ThrowManagedException: converta a Objective-C exceção em uma exceção gerenciada e gere a exceção gerenciada. Esse é o padrão no .NET e no watchOS em projetos herdados do Xamarin.
  • Abort: anula o processo.
  • Disable: desabilita a interceptação de exceção, portanto, não faz sentido definir esse valor no manipulador de eventos, mas depois que o evento é acionado, é tarde demais para desabilitá-lo. De qualquer forma, se definido, ele anulará o processo.

Portanto, para ver sempre que uma exceção é empacotada, você pode fazer isso:

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

Sinalizadores de Build-Time

É possível passar as seguintes opções para mtouch (para aplicativos Xamarin.iOS) e mmp (para aplicativos Xamarin.Mac), que determinarão se a interceptação de exceção está habilitada e definirá a ação padrão que deve ocorrer:

  • --marshal-managed-exceptions=

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

    • default
    • unwindmanagedcode
    • throwmanagedexception
    • abort
    • disable

Com exceção de disable, esses valores são idênticos aos ExceptionMode valores passados para os MarshalManagedException eventos e MarshalObjectiveCException .

A disable opção desabilitará principalmente a interceptação, exceto que ainda interceptaremos exceções quando ela não adicionar nenhuma sobrecarga de execução. Os eventos de marshaling ainda são gerados para essas exceções, com o modo padrão sendo o modo padrão para a plataforma em execução.

Limitações

Interceptamos apenas P/Invokes para a objc_msgSend família de funções ao tentar capturar Objective-C exceções. Isso significa que um P/Invoke para outra função C, que, em seguida, gera quaisquer Objective-C exceções, ainda encontrará o comportamento antigo e indefinido (isso pode ser melhorado no futuro).