Is it safe to throw objc exceptions across Swift stack frames?

I understand that Swift exceptions errors (edited for clarity) are completely different from objc exceptions, and how Swift exceptions map to NSError ** params in bridged Objc, etc.

However, what happens when some Swift sits between two levels of throwing/catching objc like this:

pseudo Stack Frame:

3. Baz.m#objCMethodThatThrows ---------.  Expecting to unwind back to Foo, 
2. Bar.swift#swiftMethodThatCallsObjC  |  but sometimes terminating in Baz.
1. Foo.m#objCMethodThatCatches <-------' 

(example implementations below if you need more detail)

Background: We're using more and more Swift, but have a lot of legacy objc code to deal with. Some of that legacy code uses exceptions for control flow. (Whether we should be using objc exceptions for control flow like this is outside of the scope of my question :wink:).

We now have a situation where some high level code catches in objc, some low level code throws in objc, but some recently introduced middle layer code is written in Swift.

My question is, is there something about unwinding the stack across a swift frame that causes the app to crash or is this supposed to be supported? In practice it seems to generally work, but we're seeing a crash in production.

I can't find any documentation about this particular case, but it appears to work in DEBUG while developing. And even a synthetic example like the code given below works in debug and release builds. I haven't yet determined the simplest failure case (e.g. does the swift have to cross a non-@ objc frame, or a specialized frame to crash?)

However, in our actual application, which is substantially more complicated, we're seeing something similar crash in production (test flight) builds at the point of the exception being thrown in the objc code. That is - an objc try block, calls a class implemented in swift, which in turn, calls some other objc code which @throws, and the crash report indicates termination at the point of the @throw, as if it were not inside a try/catch block.

[example implementations]

// Foo.m
@implentation Foo

- (void)objcMethodThatCatches
{
    @try {
        [[Bar new] swiftMethodThatCallsObjC]
    } @catch (NSException *exception) {
        // application terminated before it gets here.
        [SomeOtherClass handleException:exception];
    }
}

@end
// Bar.swift
@objc
class Bar: NSObject {

    @objc
    func swiftMethodThatCallsObjC() {
        Baz().objcMethodThatThrows()
    }
}
// Baz.m

@implentation Baz

- (void)objcMethodThatThrows
{
     // application terminated here
    @throw [NSException exceptionWithName:@"blah" readon:@"blah" userInfo:nil];
}

@end

example work around

In the meanwhile, I can work around the crash by executing the swift code in a more proximate try/catch like this, but as we'd need to do this in several places, it's not ideal.

// WrapExceptions.m
@implementation WrapExceptions

- (BOOL)doBlock:(void ^(void))block error:(NSError **)outError
{
    @try {  
        block();
        return YES;
    } @catch (NSException *exception) {
        // pass exception in userInfo
        *outError = [NSError makeErrorWithDomain:"blah" code:123 userInfo:@{@"WrappedException": exception}];
        return NO;
    }
}

@end
// Foo.m
@implentation Foo

- (void)objcMethodThatCatches
{
    @try {
        NSError *error;
        [[Bar new] swiftMethodThatCallsObjCReturningError:&error];
        if (error) {
            NSException *exception = error.userInfo[@"WrappedException"]
            @throw exception
        }
    } @catch (NSException *exception) {
        [SomeOtherClass handleException:exception];
    }
}

@end
// Bar.swift
@objc
class Bar: NSObject {

    @objc
    func swiftMethodThatCallsObjC() throws {
        // turn objc exception into an error
        try WrapExceptions.do {
            Baz().objcMethodThatThrows()
        }
    }
}
// Baz.m

@implentation Baz

- (void)objcMethodThatThrows
{
    @throw [NSException exceptionWithName:@"blah" readon:@"blah" userInfo:nil];
}

@end
1 Like

I think we need to be clear here: you understand that there are no Swift exceptions, and that Swift's error model may use try, throw and catch, but isn't exception based, and there is no mapping between Obj-C exceptions and Swift errors, right?

That said, I'll set someone familiar with the runtime explain why it works the way it does (sounds like a bug), but I'll add that I've seen something like your workaround work well in practice. It lets things work natively in Swift while forcing the Obj-C code to stop using exceptions.

1 Like

Yes, I do understand that there is no mapping between Obj-C exceptions and Swift errors. I'll update my post to say "Swift Errors", not "Swift exceptions" to avoid any confusion.

No.

This is correct, it's not safe to throw an exception through Swift stack frames, even if the Swift code is totally unaware of the exception and you have some wrapper ObjC on the other side waiting to catch it.

I don't think there's detailed documentation about this; the closest I know about is Handle Exceptions in Objective-C Only. I had a whiteboard drawing showing an exception tunneling through Swift stack frames with a "not allowed" type label around it; but it didn't translate to the final document.

6 Likes

@michaelkirk Are you using Objective-C++ or the -fobjc-arc-exceptions option?

https://clang.llvm.org/docs/AutomaticReferenceCounting.html#exceptions

Yes, both. I’m curious how that might affect things?

Correct.

2 Likes

@michaelkirk ARC is not exception-safe by default, unless using Objective-C++ or the -fobjc-arc-exceptions option. I just mentioned this in case your app was leaking memory or other resources.

In your "Foo.m" example workaround, it might be safer to check the BOOL return value of the swiftMethodThatCallsObjCReturningError: call. See the Using and Creating Error Objects documentation:

Important: Success or failure is indicated by the return value of the method. Although Cocoa methods that indirectly return error objects in the Cocoa error domain are guaranteed to return such objects if the method indicates failure by directly returning nil or NO, you should always check that the return value is nil or NO before attempting to do anything with the NSError object.

1 Like

As an aside, I've never really understood that note. If returns are guaranteed from Foundation, and I can make the same guarantee about my code, why check the return unless you actually care about the value? Is is just for completeness sake? Or some subtle best practice?

The Cocoa convention is that the memory referenced by the NSError** pointer is undefined unless an error is really raised, so you could be looking at garbage or a false-positive error pointer that was left there if you don't check the return value first. A lot of MRR Objective-C framework code, as well as bridging code generated by the Swift compiler, does not write through the error pointer at all if no error is raised.

4 Likes

Ah, thought I remembered something like that. Am I reading it correctly that ARC Obj-C doesn't do that (likely because it does initialize pointers)?

Yeah, ARC will zero-initialize the pointed-to memory in the caller when you pass an NSError** pointer, or any other indirected out pointer argument, because it needs to be able to reliably memory-manage the assigned result and doesn't have special knowledge of Cocoa's return value conventions for errors. The callee could still be MRR, but AIUI it has to be well behaved enough to either leave the pointed-to memory alone or place a valid pointer to some object there. (A valid object pointer nonetheless by convention only has meaning as an error if the return value says there was an error.)

2 Likes

Right. But note that that ObjC ARC does trust the callee to not overwrite the out-parameter with true garbage: when you pass &someStrongLocal to an __autoreleasing out-parameter, ARC unconditionally takes ownership of the value after resuming in the caller, so if garbage was stored there it would crash. We haven't seen widespread problems with that. So the main reason not to just check the NSError** value is that it's allowed to contain a false-positive error even if the operation didn't fail.

4 Likes

Thanks for the information. I’ve adjusted my expectations accordingly.

Note that this is something we're considering in the long term, e.g. to support localized actor teardown in a server context. It'd be desirable for that to interoperate with exceptions-style stack unwinding so that we can also tear down resources held by C++ / ObjC functions that were in progress when the stack was torn down. But it's not certain that we'll want to do that, and even if we do, I think we'll probably want it to be opt-in because it's likely to come with a non-trivial cost.

1 Like