Swift 6 Gave Me Zero Warnings, but Two Objective-C Callbacks Still Crashed at Runtime

Wait 5 sec.

Three crash reports came off my iPhone over three days, all identical:# summarized from the .ips JSONException: EXC_BREAKPOINT (SIGTRAP)Queue: com.apple.BGTaskScheduler (…skymoment.refresh)Stack: _dispatch_assert_queue_fail dispatch_assert_queue _swift_task_checkIsolatedSwiftMy app, Amana, draws the sky and nudges you to go outside and look at the real one. It's built in Swift 6 language mode, and I had also tried SWIFT_STRICT_CONCURRENCY=complete. The compiler said nothing about the code that crashed: zero warnings. The tests were green. And yet the app was dying in the background.A week later, the same crash came through a different Apple API. This post covers both, plus a minimal reproduction rebuilt today on Xcode 27 / Swift 6.4.The first crash: BGTaskSchedulerHere's the code that shipped (trimmed, comment translated):@MainActorfinal class SkyMomentBackgroundRefresher { func register(manager: SkyMomentManager) { // The launch handler is called from an arbitrary thread, // so it crosses the @MainActor boundary: wrap it in a Task. _ = BGTaskScheduler.shared.register( forTaskWithIdentifier: SkyMomentBackgroundPlanner.taskIdentifier, using: nil ) { [weak self] task in Task { @MainActor in self?.handle(task) } } }}The comment shows I knew the handler would arrive on another thread. The intent was right; the hop was one level too deep.A closure written inside a @MainActor method is inferred to be @MainActor-isolated itself, unless its type says otherwise (for example, @Sendable). So the closure's own body, the part that creates the Task, is declared main-actor-only. using: nil tells BGTaskScheduler to pick its own queue, and the header documents that as a default background queue. The OS calls the closure off the main queue, and the Swift runtime checks, on entry, whether it is on the main actor's executor. It isn't. EXC_BREAKPOINT.The Task { @MainActor in } never gets a chance to run, because the check fires before the first line of the body.The fix was to make the queue match the declaration:_ = BGTaskScheduler.shared.register( forTaskWithIdentifier: SkyMomentBackgroundPlanner.taskIdentifier, using: .main) { [weak self] task in self?.handle(task)}The obvious alternative, marking the closure @Sendable so it's no longer main-actor-isolated, doesn't compile. BGTask isn't Sendable, so handing it to the main actor is rejected: error: sending 'task' risks causing data races. That's the compiler being right. (I re-checked this against the real iOS 27 SDK.)I shipped this fix without a positive control. The simulator's _simulateLaunchForTaskWithIdentifier appears to invoke the handler on the main queue: the broken code didn't crash there either. My evidence was three crash logs and the shape of the stack. I wrote that down in the commit message, because "it stopped crashing in the simulator" would have proved nothing.A week later: PhotoKitAmana has a "Save to Photos" button. In version 1.0.7 it crashed every single time. That code had been in the repo since nine days before the BGTask fix. I'd learned the pattern and never went looking for a second instance. The crash had the same exception and the same _swift_task_checkIsolatedSwift frame, and this time the queue was com.apple.PHPhotoLibrary.changes.@MainActorenum SkyPhotoSaver { static func save(_ image: UIImage) async -> Outcome { // … authorization check … do { try await PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.creationRequestForAsset(from: image) } return .saved } catch { return .failed } }}Same trap: the change block is written inside a @MainActor type, so it's main-actor-isolated. PhotoKit runs it on its own private queue. There's no using: parameter, so I couldn't pin the queue. The fix went the other way and moved the closure out of the actor:nonisolated private static func addToLibrary(_ image: UIImage) async throws { try await PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.creationRequestForAsset(from: image) }}This time the simulator could reproduce it, because PhotoKit uses its real private queue there too. With a regression test and photo permission granted, the fixed code passed in 0.220 seconds. With the fix reverted, the run ended in "Restarting after unexpected exit, crash". That's the positive control I didn't have the first time.The regression test that tested nothingMy first version of that test looked reasonable:let outcome = await SkyPhotoSaver.save(image)#expect(outcome == .saved || outcome == .denied)Four separate review passes flagged the same problem. Without photo permission, save() returns .denied before it ever reaches performChanges. On a fresh simulator or a CI machine, the test would go green without touching the code it was written to protect.The fix is to make the precondition explicit, so a missing permission shows up as a skip instead of a pass:nonisolated static var canAddToPhotoLibrary: Bool { let status = PHPhotoLibrary.authorizationStatus(for: .addOnly) return status == .authorized || status == .limited}@Test(.enabled(if: canAddToPhotoLibrary))func saveDoesNotCrashOnPhotoKitQueue() async { // trimmed; name translated let outcome = await SkyPhotoSaver.save(onePixelImage) #expect(outcome == .saved)}One detail: canAddToPhotoLibrary has to be nonisolated, because the test struct is @MainActor and the trait evaluates the condition from a Sendable closure. It's the same mismatch, one level down. And if the isolation ever breaks again, the test won't fail. The process will die.Why the compiler can't see itThis is what Swift sees for PhotoKit's method in the iOS 27 SDK:func performChanges(_ changeBlock: @escaping () -> Void, completionHandler: (@Sendable (Bool, (any Error)?) -> Void)? = nil)The completion handler is @Sendable. The change block, in the same declaration, is not. In Objective-C it's a plain dispatch_block_t with no NS_SWIFT_SENDABLE. BGTaskScheduler's launchHandler is the same: @escaping (BGTask) -> Void.A non-Sendable closure parameter is a promise from the callee: I won't send this closure to another isolation domain. When the callee is written in Swift, the compiler holds it to that:func runOnBackgroundQueue(_ block: @escaping () -> Void) { Task.detached { block() } // error: passing closure as a 'sending' parameter risks causing data races…}With DispatchQueue.global().async { block() } instead, you get a warning rather than an error (capture of 'block' with non-Sendable type '() -> Void' in a '@Sendable' closure), because Dispatch's async is declared @preconcurrency. Either way, the problem is flagged in the callee.An Objective-C callee has no Swift body to check. The header is all Swift gets. Its comments may say which queue will run the block, but the block's type doesn't, and the type is what the compiler checks. So the compiler can't prove the call safe, and it can't prove it unsafe either. It does what SE-0423 specifies for "synchronous actor-isolated function values passed to APIs that erase actor isolation" when those APIs haven't adopted strict concurrency checking: it inserts a runtime check at the closure's entry. The trap is that the check is doing its job. It isn't a compiler bug.A minimal reproductionThe Objective-C side has the same shape as performChanges::+ (void)runOnBackgroundQueue:(dispatch_block_t)block { dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), block);}The Swift side, in Swift 6 language mode:@MainActorfinal class Owner { var counter = 0 func work() { counter += 1 } func broken() { Shim.runOnBackgroundQueue { [weak self] in self?.work() } }}Those are the two targets of a small SwiftPM command-line package for macOS, with one variant per row below. It runs on the same Swift concurrency runtime and dies with the same stack as the crash reports above; those reports are the evidence from real devices. If you rebuild it, keep the process alive with CFRunLoopRun(): with dispatchMain(), my main-queue work reported Thread.isMainThread == false, which muddies any thread check. Every build started from an empty build directory, and I planted a deliberate warning first to check that my counter catches one. Zero warnings with and without -strict-concurrency=complete, and the same runtime results in debug and release builds:VariantWarningsResultClosure passed to a plain block, run on a global queue0SIGTRAPSame, but the body only prints (touches nothing isolated)0SIGTRAPBody is Task { @MainActor in … }0SIGTRAPBGTask shape, using: nil0SIGTRAPBGTask shape, using: .main0runs on mainClosure formed in a nonisolated function0runs on a background queue, fineBlock marked NS_SWIFT_SENDABLE0runs on a background queue, no trapClosure assigned to an Objective-C block property0no crash (see below)Rows two and three are the ones worth remembering. The check doesn't look at what the closure does. It enforces where the closure is declared to run.With -disable-dynamic-actor-isolation (SE-0423 provides it and discourages it), the broken variant ran work(), a @MainActor method, on a background thread without complaint. The BGTask shape ran fine, because the Task carried the body to main. So the BGTask crash didn't mean that handler was racing. It meant the closure broke its isolation contract, which in general is how races start.Finally, the two real before-fix snippets, reduced to the lines that matter and compiled against the iOS 27 SDK in Swift 6 mode, produced zero warnings.The shape that doesn't even crashBGTask has an expirationHandler property, and Swift imports it as (() -> Void)?. In my repro, a closure assigned to a block property like that from a @MainActor method got no warning, and no trap. It incremented main-actor state from a global queue, and the process exited 0.I don't know why there's no check there, but it changes how I read that property. Amana's expirationHandler only cancels a Task and calls setTaskCompleted(success:), so I'm not worried about it. But the comment I'd left next to it predicted the same crash. The repro suggests that, if the OS ever calls it off the main queue, it will run silently instead, so I've rewritten the comment to say that. Of the two outcomes, the loud trap is the better one.Finding the rest in your codebase1. List the candidates. Grep for APIs that take a closure and run it on a queue they pick, then keep only the hits inside @MainActor code (SwiftUI views, view models, anything annotated):grep -rnE "performChanges|BGTaskScheduler.shared.register|expirationHandler|installTap|request(AVAsset|PlayerItem|ExportSession)|start[A-Za-z]*Updates\(to:" --include="*.swift" .2. Read the type Swift sees, not the docs. Xcode ships a tool that prints the Swift interface of an SDK module:xcrun swift-synthesize-interface -include-submodules -language-mode 6 \ -module-name Photos -target arm64-apple-ios26.0 \ -sdk "$(xcrun --sdk iphoneos --show-sdk-path)" -o Photos.swiftgrep -n "func performChanges" Photos.swift3. Decide. On the iOS 27 SDK, this is what I found:APIClosure type in SwiftWho picks the queueVerdictPHPhotoLibrary.performChangesplainPhotoKit's private queuecrashed; move to nonisolatedBGTaskScheduler.register(…using:…)plainnil means a background queuecrashed; pass .mainBGTask.expirationHandlerplain propertynot documentedno trap in my repro (above)PHImageManager video requestsplainheader: an arbitrary queuesame shape (untested)AVAudioNode.installTapplainheader: may be off the main threadsame shape (untested)CMMotionManager start…Updates(to:)plainthe queue you pass.main is consistentNotificationCenter.addObserver(forName:…), AVPlayer time observers, NWPathMonitor.pathUpdateHandler, Core Data perform@Sendablen/athe compiler sees theseThe rule that falls out:@Sendable: nonisolated, and the compiler checks the body. When the annotation comes from an Objective-C header, main-actor misuse is only a warning, so read your warnings.Plain, you choose the queue: pass the main queue.Plain, documented to call back on the main or calling thread (PHImageManager.requestImage): leave it.Plain, the framework's own queue: create the closure in a nonisolated function.4. Test the path for real, and make the test prove it reached the dangerous line (see the .denied trap above).What I'd tell myself in AugustI had no crash reporting. Both bugs reached me only because I pulled .ips files off a phone. Those are JSON, and the faulting thread's queue name and its stack were enough to diagnose both, with no symbolication. The runtime check is loud by design, but that only helps if the crash reaches you.If you want to see the app these came from, Amana is on the App Store.