Dela via


Händelsehantering av undantag

Både hanterad kod och Objective-C har stöd för runtime-undantag (try/catch/finally-satser).

Implementeringarna skiljer sig dock åt, vilket innebär att körningsbiblioteken (MonoVM/CoreCLR-runtimes och Objective-C körningsbibliotek) har problem när de stöter på undantag från andra körningsmiljöer.

Den här artikeln förklarar de problem som kan uppstå och möjliga lösningar.

Det innehåller också ett exempelprojekt, Exception Marshaling, som kan användas för att testa olika scenarier och deras lösningar.

Problem

Problemet uppstår när ett undantag utlöses, och när stacken varvar ned påträffas en ram som inte matchar den typ av undantag som utlöstes.

Ett typiskt exempel på det här problemet är när ett internt API genererar ett Objective-C undantag och sedan att Objective-C undantag på något sätt måste hanteras när stackens avrullningsprocess når en hanterad ram.

Tidigare (pre-.NET) var standardåtgärden att inte göra någonting. För exemplet ovan skulle det innebära att låta Objective-C körningen varva ned hanterade ramar. Den här åtgärden är problematisk eftersom Objective-C-körningen inte vet hur hanterade ramar ska varva ned. Till exempel kommer den inte att köra några hanterade catch eller finally satser, vilket leder till imponerande svårt att hitta buggar.

Bruten kod

Ta följande kod som exempel:

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

Den här koden genererar en Objective-C NSInvalidArgumentException i intern kod:

NSInvalidArgumentException *** setObjectForKey: key cannot be nil

Och stacktrace:n blir ungefär så här:

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

Bildrutorna 0–3 är ursprungliga bildrutor, och stackavvecklaren i Objective-C-runtime kan avveckla dessa bildrutor. I synnerhet kommer den att köra alla Objective-C @catch eller @finally satser.

Den Objective-C-stackavvecklaren kan dock inte avveckla de hanterade ramarna (ramarna 4–6): Objective-C-stackavvecklaren kommer att avveckla de hanterade ramarna, men kör ingen logik för hanterade undantag (såsom catch eller finally satser).

Vilket innebär att det vanligtvis inte går att fånga dessa undantag på följande sätt:

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

Detta beror på att Objective-C stackupplindaren inte känner till den hanterade catch satsen, och inte heller kommer finally satsen att köras.

När kodexemplet ovan är effektivt beror det på att Objective-C har en metod för att meddelas om ohanterade Objective-C undantag, , NSSetUncaughtExceptionHandlersom .NET SDK:er använder, och vid den tidpunkten försöker konvertera alla Objective-C undantag till hanterade undantag.

Scenarier

Scenario 1 – fånga Objective-C undantag med en hanterad catch-hanterare

I följande scenario är det möjligt att fånga Objective-C undantag med hjälp av hanterade catch hanterare:

  1. Ett Objective-C undantag kastas.
  2. Den Objective-C körningen går över stacken (men varvar inte ned den) och letar efter en intern @catch hanterare som kan hantera undantaget.
  3. Objective-C-körningen hittar inga @catch hanterare, anropar NSGetUncaughtExceptionHandler och anropar hanteraren som har installerats av .NET SDK.
  4. Hanteraren för .NET SDK:er konverterar Objective-C-undantaget till ett hanterat undantag och kastar det. Eftersom Objective-C-körningen inte varva ned stacken (gick bara igenom den) är den aktuella ramen samma som när det Objective-C undantaget utlöstes.

Ett annat problem uppstår här, eftersom Mono-runtime inte vet hur man avvecklar Objective-C frames korrekt.

När .NET SDK:ernas oupphörliga Objective-C undantagsåteranrop anropas är stacken så här:

 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]

Här är de enda hanterade ramarna bildrutorna 8–10, men det hanterade undantaget genereras i bildruta 0. Det innebär att Mono runtime måste avtäcka de inbyggda ramarna 0–7, vilket medför ett problem liknande det som diskuterades ovan: även om Mono runtime avtäcker de inbyggda ramarna, kommer den inte att köra några Objective-C @catch eller @finally satser.

Kodexempel:

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

@finally Och satsen kommer inte att köras eftersom Mono-körningen som varvar ned den här ramen inte känner till den.

En variant av detta är att utlösa ett hanterat undantag i hanterad kod och sedan avveckla genom inbyggda ramar för att komma till den första hanterade catch satsen.

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

Den hanterade UIApplication:Main metoden anropar den inbyggda UIApplicationMain metoden och sedan utför iOS en hel del intern kodkörning innan den hanterade AppDelegate:FinishedLaunching metoden anropas, med fortfarande många interna ramar i stacken när det hanterade undantaget genereras:

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

Bildrutorna 0-1 och 27-30 hanteras, medan alla bildrutor däremellan är inbyggda. Om Mono varvar ned genom dessa ramar körs inga Objective-C @catch eller @finally satser.

Viktigt!

Endast MonoVM-körningen stöder avspolning av interna ramar under hantering av hanterade undantag. CoreCLR-körningen avbryter bara processen när den här situationen uppstår (CoreCLR-körningen används för macOS-appar samt när NativeAOT är aktiverat på valfri plattform).

Scenario 2 – Objective-C undantag kan inte fångas

I följande scenario går det inte att fånga Objective-C undantag med hanterade catch hanterare eftersom Objective-C-undantaget hanterades på ett annat sätt:

  1. Ett Objective-C-undantag kastas.
  2. Den Objective-C körningen går över stacken (men varvar inte ned den) och letar efter en intern @catch hanterare som kan hantera undantaget.
  3. Objective-C-körningen hittar en @catch hanterare, varvar ner stacken och börjar köra @catch hanteraren.

Det här scenariot finns ofta i .NET för iOS-appar, eftersom det i huvudtråden vanligtvis finns kod som den här:

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

Det innebär att det i huvudtråden aldrig finns något ohanterat Objective-C undantag, och därför anropas aldrig vår motringning som konverterar Objective-C undantag till hanterade undantag.

Det här är också vanligt när du felsöker macOS-appar på en tidigare macOS-version än den senaste, eftersom de flesta gränssnittsobjekt i felsökningsprogrammet försöker hämta egenskaper som motsvarar väljare som inte finns på den körbara plattformen. Om du anropar sådana väljare utlöser du en NSInvalidArgumentException ("Okänd väljare som skickas till ..."), vilket så småningom gör att processen kraschar.

Sammanfattnings nog kan det leda till odefinierade beteenden, till exempel krascher, minnesläckor och andra typer av oförutsägbara (felaktiga) beteenden om du har antingen Objective-C körning eller mono-körningen som de inte är programmerade att hantera.

Tips/Råd

För macOS- och Mac Catalyst-appar (men inte iOS- eller tvOS)-appar är det möjligt att göra så att användargränssnittsloopen inte fångar alla undantag genom att ange NSApplicationCrashOnExceptions egenskapen för appen till true:

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

Observera dock att den här egenskapen inte dokumenteras av Apple, så beteendet kan ändras i framtiden.

Lösning

Vi har stöd för att fånga både hanterade och Objective-C undantag på alla hanterade interna gränser och för att konvertera undantaget till den andra typen.

I pseudokod ser det ut ungefär så här:

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

P/Invoke till objc_msgSend fångas upp och den här koden anropas i stället:

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

Och något liknande görs för det omvända fallet (marshaling managed exceptions to Objective-C exceptions).

I .NET är det alltid aktiverat som standard att konvertera hanterade undantag till Objective-C undantag.

Avsnittet Skapa tidsflaggor förklarar hur du inaktiverar avlyssning när det är standard.

Evenemang

Det finns två händelser som aktiveras när ett undantag har avlyssnats: Runtime.MarshalManagedException och Runtime.MarshalObjectiveCException.

Båda händelserna skickas ett EventArgs-objekt som innehåller det ursprungliga undantaget som utlöstes (Exception-egenskapen) och en ExceptionMode-egenskap som definierar hur undantaget ska hanteras.

Egenskapen ExceptionMode kan ändras i händelsehanteraren för att ändra beteendet enligt all anpassad bearbetning som utförs i hanteraren. Ett exempel skulle vara att avbryta processen om ett visst undantag inträffar.

ExceptionMode Att ändra egenskapen gäller för den enskilda händelsen, det påverkar inte några undantag som fångas upp i framtiden.

Följande lägen är tillgängliga när hanterade undantag konverteras till intern kod:

  • Default: För närvarande är det alltid ThrowObjectiveCException. Standardvärdet kan ändras i framtiden.
  • UnwindNativeCode: Detta är inte tillgängligt när du använder CoreCLR (CoreCLR stöder inte avspolning av intern kod, det avbryter processen i stället).
  • ThrowObjectiveCException: Konvertera det hanterade undantaget till ett Objective-C-undantag och kasta Objective-C-undantaget. Detta är standardvärdet i .NET.
  • Abort: Avbryt processen.
  • Disable: Inaktiverar undantagsavlyssning. Det är inte meningsfullt att ange det här värdet i händelsehanteraren (när händelsen har aktiverats är det för sent att inaktivera avlyssning av undantaget). I vilket fall som helst, om det anges, fungerar det som UnwindNativeCode.

Följande lägen är tillgängliga när du konverterar Objective-C undantag till hanterad kod:

  • Default: För närvarande är det alltid ThrowManagedException i .NET. Standardvärdet kan ändras i framtiden.
  • UnwindManagedCode: Det här är det tidigare (odefinierade) beteendet.
  • ThrowManagedException: Konvertera undantaget Objective-C till ett hanterat undantag och kasta det hanterade undantaget. Detta är standardvärdet i .NET.
  • Abort: Avbryt processen.
  • Disable: Inaktiverar undantagsavlyssning. Det är inte meningsfullt att ange det här värdet i händelsehanteraren (när händelsen har aktiverats är det för sent att inaktivera avlyssning av undantaget). I vilket fall som helst, om det anges, fungerar det som UnwindManagedCode.

Så om du vill se varje gång ett undantag hanteras kan du göra följande:

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

Tips/Råd

Helst bör Objective-C undantag inte förekomma i en applikation som fungerar korrekt (Apple anser att de är betydligt mer exceptionella än hanterade undantag: "undvik att utlösa [Objective-C]-undantag i en app som du skickar till användare"). Ett sätt att åstadkomma detta är att lägga till en händelsehanterare för händelsen Runtime.MarshalObjectiveCException som skulle logga alla marskalkade Objective-C undantag med hjälp av telemetri (för felsökning/lokala versioner kanske också ställer in undantagsläget på "Avbryt" för att identifiera alla sådana undantag för att åtgärda/undvika dem.

Build-Time flaggor

Det är möjligt att ange följande MSBuild-egenskaper, som avgör om undantagsavlyssning är aktiverat och anger den standardåtgärd som ska utföras:

  • MarshalManagedExceptionMode: "default", "unwindnativecode", "throwobjectivecexception", "abort", "disable".
  • MarshalObjectiveCExceptionMode: "standard", "linda upp hanterad kod", "kast hanterat undantag", "avbryt", "inaktivera".

Exempel:

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

disableFörutom är dessa värden identiska med de ExceptionMode värden som skickas till händelserna MarshalManagedException och MarshalObjectiveCException.

Alternativet disable inaktiverar mestadels avlyssning, förutom att vi fortfarande hanterar undantag när det inte medför någon extra kostnad för körning. Marshaling-händelserna aktiveras fortfarande för dessa undantag, där standardläget är det för den utförande plattformen.

Begränsningar

Vi fångar bara upp P/Invokes till objc_msgSend serien av funktioner när vi försöker hantera Objective-C undantag. Det innebär att en P/Invoke till en annan C-funktion, som sedan genererar eventuella Objective-C undantag, fortfarande kommer att stöta på det gamla och odefinierade beteendet (detta kan förbättras i framtiden).

Se även