Mysterious Runtime Crashes in Swift 6 Language Mode

Mysterious Runtime Crashes in Swift 6 Language Mode

Swift 6 language mode promises compile-time stricter concurrency checking, aimed at preventing and eliminating data races, runtime memory corruption, as well as other common but hard to debug issues. While adopting the new language mode can be a bit of a challenge for larger or less modular projects, Swift 6 is an obvious choice for new targets.

However, it is worth noting that Swift 6 language mode also includes new runtime checks and can make some of the existing checks more aggressive. Many of them become preemptive assertions rather than silent failures. Surprisingly, this can lead to hard to reproduce occasional runtime crashes in production when interacting with common first-party modules like SwiftUI and AppKit.

SwiftUI Runtime Crashes

Incorrect threading that violates the concurrency declarations of these frameworks may therefore cause runtime crashes in your applications. One such example is the new SwiftUI onGeometryChange modifier, which I wrote about in some detail as part of an earlier post.

Here is an excerpt of a stack trace of one of these crashes captured by an analytics platform:

Thread 3 Crashed::  Dispatch queue: com.apple.SwiftUI.DisplayLink
0   libdispatch.dylib                      0x190b379c0 _dispatch_assert_queue_fail + 120
1   libdispatch.dylib                      0x190b37948 dispatch_assert_queue + 196
2   libswift_Concurrency.dylib             0x2712f2e08 swift_task_isCurrentExecutorImpl(swift::SerialExecutorRef) + 284
3   SomeApp                            0x102262edc 0x102260000 + 11996
4   SwiftUI                                0x1bffd8748 _GeometryActionModifier.value(geometry:) + 28
5   SwiftUI                                0x1bffdc268 partial apply for closure #1 in GeometryActionBinder.updateValue() + 52
6   SwiftUICore                            0x2303feae0 partial apply for closure #1 in _withObservation<A>(do:) + 48
7   SwiftUICore                            0x2303fd214 closure #1 in _withObservation<A>(do:)partial apply + 16
8   SwiftUICore                            0x23046d5ec withUnsafeMutablePointer<A, B, C>(to:_:) + 160
9   SwiftUICore                            0x2303fc664 StatefulRule.withObservation<A>(do:) + 872
10  SwiftUICore                            0x2303fc264 StatefulRule.withObservation<A>(do:) + 72

The onGeometryChange modifier contains a closure that is implicitly non-Sendable. This means the closure is expected to execute on the main thread. However, in some cases SwiftUI ends up calling it on a non-main thread, or, in this case, com.apple.SwiftUI.DisplayLink.

The tricky part in diagnosing an issue like this is that it may not be reproducible on demand and may depend on the device, platform, and the larger context, such as the load of the system. It's entirely possible to go through all stages of testing and end up shipping this runtime crash to production.

You might also think you can fix this issue by immediately dispatching to the main thread, using either DispatchQueue or a MainActor annotated Task:

contentView
    .onGeometryChange(for: CGSize.self, of: \.size) { value in
        DispatchQueue.main.async {
            // Do some work here.
        }
    } 

However, this will still cause the assertion to trigger, as the check happens on the closure executed by SwiftUI, not the one inside the async block you provided.

This situation happens when the imported module has not adopted strict concurrency yet and / or has not annotated its API with correct sendability expectations. In this case, the closure is likely expected to be Sendable but is not correctly marked as such, which leads the runtime to assume it is implicitly not Sendable.

The exact case is outlined in the official Swift 6 Migration Guide.

The sendability of a closure affects how the compiler infers isolation for its body. A callback closure that actually does cross isolation boundaries but is missing a Sendable annotation violates a critical invariant of the concurrency system.

The fix, until the imported module annotates the API it vends, is to manually annotated the closure you provide with @Sendable, like so:

contentView
    .onGeometryChange(for: CGSize.self, of: \.size) { @Sendable value in // This is now sendable!
        // Do some work or dispatch to main queue.
    } 

Adding this annotation will have the effect of leading the compiler to assume the closure's body is nonisolated, which may lead to some new compile-time errors. So, depending on the use case, you might have to also dispatch to the main thread after all. But, this is much better than randomly crashing at runtime in production, isn't it?

Actor Isolation and AppKit

But, SwiftUI users aren't the only victims of this nasty behavior. Even if you are using a battle tested framework like AppKit, you are not exempt. Let's look at a crash of the same nature, but this time without SwiftUI involvement:

* thread #5, queue = 'com.apple.root.default-qos', stop reason = EXC_BREAKPOINT (code=1, subcode=0x1004f8a5c)
  * frame #0: 0x00000001004f8a5c libdispatch.dylib`_dispatch_assert_queue_fail + 120
    frame #1: 0x00000001004f89e4 libdispatch.dylib`dispatch_assert_queue + 196
    frame #2: 0x0000000275bd293c libswift_Concurrency.dylib`swift_task_isCurrentExecutorImpl(swift::SerialExecutorRef) + 280
    frame #3: 0x0000000275b78588 libswift_Concurrency.dylib`Swift._checkExpectedExecutor(_filenameStart: Builtin.RawPointer, _filenameLength: Builtin.Word, _filenameIsASCII: Builtin.Int1, _line: Builtin.Word, _executor: Builtin.Executor) -> () + 60
    frame #4: 0x0000000108922788 MyApp.debug.dylib`@objc static FileDocument.autosavesInPlace.getter at <compiler-generated>:0
    frame #5: 0x00000001a2f7d780 AppKit`-[NSDocument(NSDocument_Versioning) _preserveContentsIfNecessaryAfterWriting:toURL:forSaveOperation:version:error:] + 92
    frame #6: 0x00000001a2fe6d1c AppKit`__85-[NSDocument(NSDocumentSaving) _saveToURL:ofType:forSaveOperation:completionHandler:]_block_invoke_2.398 + 116

In this case, as the Swift Forums user reports, there is no closure involved. Instead, there is an NSDocument subclass annotated with MainActor. When a type is annotated MainActor, its members are expected to execute on the main actor, unless annotated as nonisolated, which voids the inherited isolation. AppKit, as a largely Objective-C framework, is not aware of these tricks. In many cases, it assumes your subclass is a normal Objective-C class, all assertions in which are either triggered by you, the client, or AppKit's internals. Therefore, it reserves the right to manage concurrency as it sees fit, invoking the members of your subclass from whatever thread necessary.

In this case, the issue may be much easier to debug, however, since the thread on which AppKit invokes the members of your subclass is much more likely to be deterministic than in the case of the AttributeGraph driven SwiftUI. Once you hit the issue, the answer would then be to annotate the problematic member with nonisolated, optionally filing a Radar with Apple requesting them to fix the Swift overlay, and moving on.

Conclusion

When you hit a runtime crash in the Swift 6 language mode, you may get lucky and hit it during the debugging process, or receive it as an angry crash report from your users. In either case, you will be much better prepared to diagnose and fix the problem if you are familiar with the Swift concurrency system and, perhaps more importantly in this context, the migration guide.