Marshalling de exceções

Tanto o código gerido como o Objective-C suportam exceções em tempo de execução (cláusulas try/catch/finally).

No entanto, as suas implementações são diferentes, o que significa que as bibliotecas de runtime (os runtimes MonoVM/CoreCLR e as bibliotecas de runtime Objective-C) apresentam problemas quando encontram exceções de outros runtimes.

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

Inclui também um projeto de exemplo, Exceptional Marshaling, que pode ser usado para testar diferentes cenários e as suas soluções.

Problema

O problema ocorre quando uma exceção é lançada e, durante o desenrolamento da pilha, encontra-se um frame que não corresponde ao tipo de exceção lançada.

Um exemplo típico deste problema é quando uma exceção Objective-C é lançada por uma API nativa, e depois essa exceção Objective-C tem de ser, de alguma forma, tratada quando o processo de desenrolamento da pilha chega a um frame gerido.

No passado (pre-.NET), a ação padrão era não fazer nada. Para o exemplo acima, isto significaria permitir que o runtime do Objective-C desempacotasse as frames geridas. Esta ação é problemática, porque o runtime do Objective-C não sabe como desenrolar frames geridos; por exemplo, não executa quaisquer cláusulas geridas catch ou finally, o que leva a bugs incrivelmente difíceis de encontrar.

Código quebrado

Considere o seguinte exemplo de código:

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

Este código irá lançar uma Objective-C NSInvalidArgumentException em código nativo:

NSInvalidArgumentException *** setObjectForKey: key cannot be nil

E o stack trace será algo como isto:

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 frames 0-3 são frames nativos, e o stack unwinder no tempo de execução Objective-C pode desfazer esses frames. Em particular, executará quaisquer Objective-C @catch ou @finally cláusulas.

No entanto, o desempilhador de stack Objective-C não é capaz de desenrolar corretamente os quadros controlados (frames 4-6), pois o desempilhador de stack Objective-C irá desenrolar os quadros controlados, mas não executará nenhuma lógica de exceção controlada (como cláusulas catch ou finally).

O que significa que, normalmente, não é possível detetar estas exceções da seguinte forma:

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

Isto acontece porque o desmantelador de stack de Objective-C não sabe da cláusula gerenciada catch , e a finally cláusula também não será executada.

Quando o exemplo de código acima é eficaz, é porque Objective-C tem um método de ser notificado de exceções de Objective-C não tratadas, NSSetUncaughtExceptionHandlerque os SDKs .NET usam, e nesse ponto tenta converter quaisquer exceções Objective-C em exceções geridas.

Cenários

Cenário 1 - capturar exceções de Objective-C com um tratador de captura gerido

No cenário seguinte, é possível detetar Objective-C exceções usando handlers geridos catch :

  1. É lançada uma exceção Objective-C.
  2. O runtime Objective-C percorre a pilha (mas não a desenrola), procurando um handler nativo @catch capaz de tratar da exceção.
  3. O runtime Objective-C não encontra nenhum @catch manipulador, chama NSGetUncaughtExceptionHandler e invoca o manipulador instalado pelo .NET SDK.
  4. O handler dos SDKs .NET converte a exceção Objective-C numa exceção gerida e lança-a. Como o tempo de execução Objective-C não desenrolou a pilha (apenas a percorreu), o frame atual é o mesmo onde foi lançada a exceção Objective-C.

Outro problema surge aqui, porque o tempo de execução do Mono não sabe como desenrolar Objective-C frames corretamente.

Quando o callback de exceção Objective-C não capturado dos SDKs .NET é chamado, a pilha é assim:

 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]

Aqui, os únicos frames geridos são os frames 8-10, mas a exceção gerida é incluída no frame 0. Isto significa que o runtime do Mono tem de desempacotar os frames nativos 0-7, o que causa um problema equivalente ao discutido acima: embora o runtime do Mono desempacote os frames nativos, não executa quaisquer cláusulas de Objective-C @catch 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");
    }
}

A @finally cláusula não será executada porque o tempo de execução Mono que processa este frame não o reconhece.

Uma variação disto é lançar uma exceção gerida no código gerido e depois desenrolar através de frames nativos para chegar à primeira cláusula gerida 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 gerido UIApplication:Main irá chamar o método nativo UIApplicationMain, e então o iOS executará uma quantidade significativa de código nativo antes de, eventualmente, chamar o método gerido AppDelegate:FinishedLaunching, com muitos frames nativos ainda na pilha quando a exceção gerida é lançada:

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

Os frames 0-1 e 27-30 são geridos, enquanto todos os frames intermédios são nativos. Se o Mono se desenrolar através destes frames, nenhuma cláusula Objective-C @catch ou @finally será executada.

Importante

Só o runtime MonoVM suporta o desenrolamento de frames nativos durante o tratamento gerido de exceções. O runtime CoreCLR simplesmente aborta o processo quando se deparar com esta situação (o runtime CoreCLR é usado para aplicações macOS, assim como quando o NativeAOT está ativado em qualquer plataforma).

Cenário 2 - não consigo detetar Objective-C exceções

No cenário seguinte, não é possível capturar exceções Objective-C usando manipuladores geridos catch porque a exceção Objective-C foi tratada de outra forma.

  1. É lançada uma exceção Objective-C.
  2. O runtime Objective-C percorre a pilha (mas não a desenrola), procurando um handler nativo @catch que consiga tratar da exceção.
  3. O tempo de execução Objective-C encontra um @catch handler, desenrola a pilha e começa a executar o @catch handler.

Este cenário é comum em .NET para aplicações iOS, porque no tópico principal normalmente há código assim:

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

Isto significa que na thread principal nunca há realmente uma exceção Objective-C não tratada, e por isso o nosso callback que converte exceções Objective-C em exceções geridas nunca é chamado.

Isto também é comum ao depurar aplicações macOS numa versão anterior à mais recente do macOS, porque inspecionar a maioria dos objetos de interface no depurador tentará obter propriedades que correspondem a seletores que não existem na plataforma em execução. Ao chamar esses seletores, aparece um NSInvalidArgumentException ("Seletor não reconhecido enviado para ..."), o que eventualmente faz com que o processo crashe.

Resumindo, ter o ambiente de execução Objective-C ou o ambiente de execução Mono a desenrolar frames para os quais não estão programados pode levar a comportamentos indefinidos, como falhas, fugas de memória e outros tipos de comportamentos inesperados ou erráticos.

Sugestão

Para aplicações macOS e Mac Catalyst (mas não para iOS ou tvOS), é possível fazer com que o loop da interface não capture todas as exceções, definindo a propriedade NSApplicationCrashOnExceptions da aplicação para true:

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

No entanto, note que esta propriedade não está documentada pela Apple, pelo que o comportamento pode mudar no futuro.

Solução

Temos suporte para apanhar exceções geridas e Objective-C em qualquer fronteira nativa gerida, e para converter essa exceção para o outro tipo.

Em pseudo-código, assemelha-se a isto:

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

O P/Invoke to objc_msgSend é intercetado, e este 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 (na transformação de exceções geridas em exceções Objective-C).

No .NET, o marshaling de exceções geridas para exceções Objective-C está sempre ativado por padrão.

A secção Flags de Tempo de Compilação explica como desativar a interceção quando esta é definida por predefinição.

Events

Existem dois eventos que são gerados quando uma exceção é intercetada: Runtime.MarshalManagedException e Runtime.MarshalObjectiveCException.

Ambos os eventos recebem EventArgs objeto que contém a exceção original que foi lançada (Exception propriedade), e ExceptionMode propriedade para definir como a exceção deve ser encaminhada.

A ExceptionMode propriedade pode ser alterada no handler de eventos para alterar o comportamento de acordo com qualquer processamento personalizado realizado no handler. Um exemplo seria abortar o processo se ocorrer uma certa exceção.

Alterar a ExceptionMode propriedade aplica-se ao evento único, não afeta quaisquer exceções interceptadas no futuro.

Os seguintes modos estão disponíveis ao transferir exceções geridas para o código nativo:

  • Default: Atualmente, é sempre ThrowObjectiveCException. A configuração padrão pode mudar no futuro.
  • UnwindNativeCode: Isto não está disponível ao usar o CoreCLR (o CoreCLR não suporta o desmantelamento de código nativo, pois irá abortar o processo).
  • ThrowObjectiveCException: Converter a exceção gerida numa exceção Objective-C e lançar a exceção Objective-C. Este é o padrão no .NET.
  • Abort: Abortar o processo.
  • Disable: Desativa a interceptação de exceções. Não faz sentido definir este valor no gestor de eventos (uma vez que o evento é ativado, já é tarde para desativar a interceção da exceção). Em todo o caso, se for definido, comportar-se-á como UnwindNativeCode.

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

  • Default: Atualmente, é sempre ThrowManagedException no .NET. O padrão pode mudar no futuro.
  • UnwindManagedCode: Este é o comportamento anterior (indefinido).
  • ThrowManagedException: Converta a exceção Objective-C numa exceção gerenciada e lance a exceção gerenciada. Este é o padrão no .NET.
  • Abort: Abortar o processo.
  • Disable: Desativa a interceptação de exceções. Não faz sentido definir este valor no gestor de eventos (uma vez que o evento é ativado, já é tarde para desativar a interceção da exceção). Em todo o caso, se for definido, comportar-se-á como UnwindManagedCode.

Portanto, para ver cada vez que uma exceção é organizada, pode fazer o seguinte:

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

Sugestão

Idealmente, Objective-C exceções não deveriam ocorrer numa aplicação bem comportada (a Apple considera-as muito mais excecionais do que exceções geridas: "evite incluir exceções [Objective-C] numa aplicação que envia aos utilizadores"). Uma forma de conseguir isto seria adicionar um gestor de eventos para o evento Runtime.MarshalObjectiveCException que registasse todas as exceções marshaled Objective-C usando telemetria (para compilações de debug/locais talvez também definir o modo de exceção para "Abortar") para detetar todas essas exceções e corrigi-las/evitar.

Build-Time Bandeiras

É possível definir as seguintes propriedades do MSBuild, que determinam se a interceção de exceções está ativada, e definir a ação padrão que deve ocorrer:

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

Exemplo:

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

Exceto por disable, estes valores são idênticos aos valores ExceptionMode que são passados para os eventos MarshalManagedException e MarshalObjectiveCException.

A disable opção desativa maioritariamente a interceção, exceto que ainda iremos interceptar exceções quando não adicionar qualquer sobrecarga de execução. Os eventos de marshaling continuam a ser ativados para estas exceções, sendo o modo predefinido o modo padrão para a plataforma em execução.

Limitações

Só intercetamos P/Invokes para a objc_msgSend família de funções quando tentamos apanhar Objective-C exceções. Isto significa que um P/Invoke para outra função C, que depois lança quaisquer exceções Objective-C, continuará a encontrar o comportamento antigo e indefinido (isto poderá ser melhorado no futuro).

Consulte também