Compartilhar via


Agrupamento de exceções

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 tempo de execução (os runtimes MonoVM/CoreCLR e as bibliotecas de tempo de execução Objective-C) têm problemas quando encontram exceções de outros runtimes.

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

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

Problema

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

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

No passado (pre-.NET), a ação padrão era não fazer nada. Para o exemplo acima, isso significaria permitir que o Objective-C runtime desenrolasse os quadros gerenciados. Essa ação é problemática, pois o runtime do Objective-C não sabe como desempilhar quadros gerenciados; por exemplo, ele não executará cláusulas gerenciadas catch ou finally, levando a bugs extremamente difíceis de encontrar.

Código quebrado

Considere o seguinte exemplo de código:

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

Esse código lançará um 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-3 são quadros nativos, e o desenrolador de pilha no runtime Objective-C pode desenrolar esses quadros. Em particular, ele executará qualquer Objective-C @catch ou @finally cláusulas.

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

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 desenrolador de pilha Objective-C não sabe sobre a cláusula gerenciada catch e nem a cláusula finally 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 sem tratamento, NSSetUncaughtExceptionHandlerque os SDKs do .NET usam e, nesse ponto, tenta converter quaisquer exceções Objective-C em exceções gerenciadas.

Cenários

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

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

  1. Uma exceção Objective-C é gerada.
  2. O runtime Objective-C percorre a pilha (mas não a desenrola), procurando um manipulador nativo @catch que possa lidar com a exceção.
  3. O runtime Objective-C não encontra nenhum manipulador @catch, chama NSGetUncaughtExceptionHandler e invoca o manipulador instalado pelo SDK do .NET.
  4. O manipulador de SDKs do .NET converterá a exceção Objective-C em uma exceção gerenciada e a lançará. Como o runtime de Objective-C não desenrolou a pilha (apenas a percorreu), o estado atual é o mesmo em que a exceção Objective-C foi lançada.

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

Quando é chamado o retorno de chamada para exceção Objective-C não capturada dos SDKs do .NET, a pilha está 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 quadros gerenciados são os quadros 8-10, mas a exceção gerenciada é lançada no quadro 0. Isso significa que o runtime Mono deve descontrair os quadros nativos 0-7, o que causa um problema equivalente ao problema discutido acima: embora o runtime Mono desfaça os quadros nativos, ele não executará nenhuma Objective-C @catch ou @finally cláusulas.

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 cláusula @finally não será executada porque o runtime Mono que desenrola esse quadro não está ciente dele.

Uma variação disso é lançar uma exceção gerenciada no código gerenciado e então desempilhar através dos 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 então o iOS executará bastante código nativo antes de eventualmente chamar o método gerenciado AppDelegate:FinishedLaunching, ainda com muitos frames 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: 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 quadros 0-1 e 27-30 são gerenciados, enquanto todos os quadros intermediários são nativos. Se o Mono descontrair esses quadros, nenhuma Objective-C @catch ou @finally cláusulas será executada.

Importante

Somente o runtime do MonoVM dá suporte ao desenrolamento de quadros nativos durante o tratamento de exceção gerenciada. O runtime do CoreCLR anulará apenas o processo ao encontrar essa situação (o runtime do CoreCLR é usado para aplicativos macOS, bem como quando NativeAOT está habilitado em qualquer plataforma).

Cenário 2 – não conseguimos capturar as exceções Objective-C

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

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

Esse cenário geralmente é encontrado no .NET para aplicativos iOS, pois no thread principal 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, na linha de execução principal, nunca realmente ocorre uma exceção Objective-C sem tratamento e, consequentemente, o nosso mecanismo de retorno que converte exceções Objective-C em exceções gerenciadas nunca será ativado.

Isso também é comum ao depurar aplicativos macOS em uma versão anterior do macOS do que a mais recente, pois 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. Chamar esses seletores lançará um NSInvalidArgumentException ("Seletor não reconhecido enviado para ..."), o que eventualmente faz com que o processo falhe.

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

Dica

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

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

No entanto, observe que essa propriedade não está documentada pela Apple, portanto, o comportamento pode mudar no futuro.

Solução

Temos suporte para capturar tanto exceções gerenciadas quanto exceções tipo Objective-C em qualquer fronteira entre sistemas gerenciados e nativos, e para converter essa exceção para o outro tipo de exceção.

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

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 para objc_msgSend é interceptado, e esse código é chamado como substituto:

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

Algo semelhante é feito para o caso inverso (encaminhamento de exceções gerenciadas para exceções Objective-C).

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

A seção Sinalizadores de tempo de compilação explica como desabilitar a interceptação quando é o comportamento padrão.

Eventos

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

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

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.

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

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

  • Default: Atualmente, é sempre ThrowObjectiveCException. O padrão pode mudar no futuro.
  • UnwindNativeCode: isso não está disponível ao usar o CoreCLR (o CoreCLR não dá suporte ao desenrolamento do código nativo, em vez disso, anulará o processo).
  • ThrowObjectiveCException: converta a exceção gerenciada em uma exceção Objective-C e gere a exceção Objective-C. Esse é o padrão no .NET.
  • Abort: anulação do processo.
  • Disable: desabilita a interceptação de exceção. Não faz sentido definir esse valor no manipulador de eventos (uma vez que o evento é gerado, é tarde demais para desabilitar a interceptação da exceção). De qualquer forma, se definido, ele se comportará como UnwindNativeCode.

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

  • Default: Atualmente, ele sempre está ThrowManagedException no .NET. O padrão pode mudar no futuro.
  • UnwindManagedCode: esse é o comportamento anterior (indefinido).
  • ThrowManagedException: Converta a exceção Objective-C em uma exceção gerenciada e lance a exceção gerenciada. Esse é o padrão no .NET.
  • Abort: anulação do processo.
  • Disable: desabilita a interceptação de exceção. Não faz sentido definir esse valor no manipulador de eventos (uma vez que o evento é gerado, é tarde demais para desabilitar a interceptação da exceção). De qualquer forma, se definido, ele se comportará como UnwindManagedCode.

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

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

Dica

Idealmente, Objective-C exceções não devem ocorrer em um aplicativo bem comportado (a Apple as considera muito mais excepcionais do que as exceções gerenciadas: "evite lançar exceções [Objective-C] em um aplicativo que você envia aos usuários". Uma maneira de fazer isso seria adicionar um manipulador de eventos para o evento Runtime.MarshalObjectiveCException que registraria todas as exceções Objective-C processadas usando telemetria (para compilações locais/de depuração, talvez configure o modo de exceção como "Abortar") para detectar todas essas exceções com o objetivo de corrigi-las ou evitá-las.

Sinalizadores Build-Time

É possível definir as seguintes propriedades do MSBuild, que determinarão se a interceptação de exceção está habilitada 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>

Com exceção de disable, esses valores são idênticos aos valores ExceptionMode que são passados para os eventos MarshalManagedException 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 P/Invokes apenas para a família de funções objc_msgSend ao tentar interceptar exceção Objective-C. Isso significa que uma função P/Invoke para outra C, que, em seguida, gera quaisquer exceções Objective-C, ainda será executada no comportamento antigo e indefinido (isso pode ser melhorado no futuro).

Consulte também