The Complex and Sad State of Exceptions

Exceptions are something most Apple developers don’t think about. But, if you work with macOS, that’s just not an option.

Checking my notes, I was a little surprised to discover that I’ve been working on this post, in various forms, for nearly ten years. Ten years! Everyone has a pet topic, and for me, apparently it’s exceptions. While I did start writing about it a long time ago, the history of exceptions for Apple development goes much further back. When I first started this post, the situation wasn’t good. I’m quite disappointed to report that the situation has only improved a small amount in that time.

We’ll get to that. First, background.

What is an Exception

Error handling is a big part of programming. In my experience, there are many situations where error handling even represents the majority of the work. Exception handling was conceived as a way to help manage the complexity of error handling.

It was a noble effort. But, in my opinion, it completely backfired.

Central to the concept of exceptions is the ability to transfer control from the site of the error to a handler. That handler can be within any calling function, and can cross library boundaries. This establishes an invisible control flow path, and does so in a way that cannot be reasoned about ahead of time. These unexpected control-flow paths can very easily cause memory leaks, resource management issues, and state corruption. In fact, the problem is so bad that exceptions have actually introduced an entirely new class of programming problem, known collectively as exception safety.

The problems exception handling introduces are profound. They are similar to, but much worse than the issues with goto. And, we’ve known this for a long time. Catching exceptions is bad.

History

Apple has a fascinating relationship with exceptions, and it is surprisingly complex. There have actually been three distinct exception handling mechanisms used with Objective-C since Mac OS X 10.0 shipped. And, iOS used a hybrid system until arm64 was introduced, so that kinda makes four.

There is some pretty interesting history in NSException.h. Things started with the NS_DURING and NS_HANDLER/NS_ENDHANDLER macros. These three macros could be used much like the more familiar try and catch statements of other languages. Internally, the exception was propagated via the magic of the POSIX APIs setjmp and longjmp. SjLj, as this is often called, allows you to perform the necessary non-local (i.e. out of the current function) jumps.

Things changed in a small, but important way with 10.3. That release brought with it a language extension that supported the @try/@catch/@finally syntax. The runtime still used SjLj under the covers, but exceptions felt a lot more integrated with the environment. They got their own syntax, after all. This was the state of the art until exceptions were turned completely on their head in 10.5.

Mac OS X 10.5 introduced Objective-C 2.0, and with the runtime’s 64-bit variant, zero-cost exceptions. “Wow!” I remember thinking. “Zero-Cost! 64-Bit!”. It felt like a big change, and even made it into the definitive review. This 64-bit Objective-C runtime also enabled binary compatibility with C++ exceptions. The syntax was the same, but boy oh boy, were things different internally.

Exceptions were now implemented using something called DWARF CFI. This is a low-level mechanism for describing arbitrary register mutations as a function of the current state of a thread. It is extremely complex, and also resource-intensive when throwing. But, it has the big advantage of having zero runtime overhead when entering a try block.

DWARF CFI is part of a standard, but Apple decided to add their own flavor to the exception propagation system. They added something called compact_unwind, which is dramatically simpler, but can only describe a limited number of situations. Those situations were the common case, however, so I have to assume this was a significant win for runtime performance.

When iOS shipped, it used an identical system to Objective-C 2.0 on macOS, but went with a SjLj implementation. It was an interesting hybrid, and it remained in place until arm64. At that transition, Apple took the time to bring compact_unwind and DWARF CFI to iOS as well.

Interestingly, due to some significant ABI differences, compact_unwind on arm64 can describe many more situations. So many, that it is extremely unusual for DWARF CFI to be required for an iOS binary. I went looking for instances at one point, while working on DWARF CFI support for Crashlytics. I searched through many examples of debug info, and was able to find exactly one function with DWARF CFI. It was some kind of C++ destructor, naturally.

The Woes

Just as the exception implementation system has evolved, so has Apple’s use of exceptions within their own frameworks. In fact, macOS has a surprisingly large amount of framework-level integration with exceptions. As far as I know, none have survived the transition to iOS, and thank goodness. Because the situation on macOS is not good.

Problem #1: AppleEvents

Under the hood, many kinds of events for macOS apps are driven by the AppleEvent system. For the most part, this is an implementation detail that does not actually impact you at all. Except for one big one: the AppleEvents system catches exceptions. And, as it turns out, it is the AppleEvent system that ultimately invokes applicationDidFinishLaunching. Here’s a good post that summarizes the problem.

This means that if your app throws an exception during initialization, you do not crash. Your app just starts running with incomplete initialization. This has been a source of significantly weird bugs for myself, and I would imagine that it is a very common failure-mode for macOS apps in general. Of course, we’ll never know because this silent failure is extremely difficult to capture and report without resorting to pretty terrible hacks.

Problem #2: AppKit

Another common event path is routed through AppKit and NSApplication internals. These paths also catch exceptions. Of course, AppKit itself is horrendously exception-unsafe. So, once this happens, AppKit typically corrupts both your app’s state and its own. Your app will likely cease to function correctly from that point on. Maybe it crashes later on, maybe not.

Mercifully, AppKit includes a reasonably well-known default that controls this behavior. NSApplicationCrashOnExceptions causes NSApplication to terminate the app if it catches an exception.

If you are an AppKit developer, stop what you are doing right now and make sure you have this set.

Problem #3: XPC

While I do not have personal experience here, I stumbled across a Stack Overflow post that seems to indicate that the XPC system also catches exceptions. I don’t have much to add to this one, because I haven’t run into it in person. But, I’m certain it is responsible for many bugs.

I’m disappointed that this kind of behavior would be introduced into such a relatively new framework. By now, I would have assumed everyone would know better.

Problem #4: ExceptionHandling.framework

There’s a macOS-only framework called “ExceptionHandling.framework”. It provides a bunch of facilities for intercepting runtime exceptions, signals, and mach exceptions. I haven’t experimented with it in a long time. But, based on my recollection, it had some really terrible behavior in each of these cases.

It had some fundamental implementation issues, and so I opened a few radars. In one, I received a response that said, more or less, “this framework is basically deprecated, do not use it”. It should really be officially deprecated.

If you are making use of this framework, you should really stop right now. It doesn’t work correctly. You might find Impact helpful if you need custom crash reporting facilities.

Swift

I was incredibly relieved to find that Swift left exceptions out in the initial implementation. It was actually one of the first things I tested when experimenting with it. However, don’t think you are safe. Exceptions can still “pass over” Swift frames, so Swift definitely isn’t immune to exception safety issues.

I have to say I was a little dismayed to see the introduction of Swift’s error-throwing system. Thankfully, the implementation isn’t subject to the non-local jump phenomenon. But, it is still possible to get tripped up by the implicit code path a throw can cause. Overall, I don’t care for it, but I’m not sure I could have come up with a better system. Error handling is a hard problem.

Conclusion

iOS developers have, for the most part, been spared all the pain of this exception handling nonsense. There are a few places, however, where even they can run into problems. A handful of Apple APIs throw exceptions for non-exceptional circumstances. An example is NSFileHandle. Luckily, recently Apple has been putting efforts in to fix these. More of this please!

But, fixing these APIs is just not enough. I’ve actually made it a point to complain to AppKit engineers every time I make it to WWDC about this topic. A common concern I’ve heard is around binary compatibility. If the behavior is changed, apps that were previously “working” will now start crashing. I’m going to bet that for every crash this prevents, there are 1000’s of bugs it introduces. Plus, that crash is at least detectable and fixable. Silent, undetectable state corruption is just about the worst possible option you could choose. There is someone, somewhere filing a bug with Apple right now that will be completely unreproducible. Why? Because it was caused by this exception-catching behavior.

Catching exceptions is almost never the right option, and Apple needs to stop doing it.

Sat, Feb 01, 2020 - Matt Massicotte

Previous: Getting a Handle on Operations
Next: Chime 1.0 is Available