"Build 8 worked on the phone. Build 9 died the instant the microphone was tapped. The source was identical."
The Evening It Happened
A family health app on iOS had push-to-talk dictation, written as app-local code compiled in Swift 5 mode. It worked on Dad's iPhone. That evening the dictation code was donated to the shared Swift kit — a package declaring Swift 6 language mode — and the app adopted the kit's version straight back as TestFlight build 9. Dad tapped the microphone and the app died. Every time. Seventy-four tests had passed on the Simulator, and passed again on a cabled iPad.
The crash report, read bottom-up, tells the whole story. The audio framework's realtime messenger thread called into the app's closure, and the closure's first act was to ask "am I on the right executor?" It was not, so it trapped:
EXC_BREAKPOINT (SIGTRAP) thread: RealtimeMessenger.mServiceQueue
_dispatch_assert_queue_fail
dispatch_assert_queue
_swift_task_checkIsolatedSwift
swift_task_isCurrentExecutorWithFlagsImpl
Forge 0x9e998
AVFAudio AVAudioNodeTap::TapMessage::RealtimeMessenger_Perform()
The Mechanism
- The tap was installed from a method of a
@MainActorclass. - In Swift 6 mode, a closure that is not
@Sendable, written inside a main-actor-isolated context, is inferred to be main-actor isolated. - For an isolated closure handed to an API the compiler cannot fully check, Swift 6 emits a runtime isolation check at the closure's entry. Nothing in the source asked for it.
AVAudioNodeTapBlockis an imported Objective-C block type and is not@Sendable, so nothing refused the handoff at compile time.AVAudioEnginecalls a tap on a realtime audio thread — that is the point of a tap. The check fails. SIGTRAP.
In Swift 5 mode the same lines produce, at most, a warning, and no runtime check. That is why build 8 lived.
The Fix, and What It Uncovered
Give the closure no isolation to inherit: write its type with @Sendable, or build it in a nonisolated factory and pass the result. Marking the captured sink @unchecked Sendable was already true and did not help — the isolation lives on the closure, not on what it captures. Three more closures in the same file had the same shape and had not crashed only because the tap fired first: the speech recognizer's result handler and both permission callbacks (a static member of a @MainActor type is isolated too, so nonisolated static is the fix there). And once the recognizer callback was honestly @Sendable, the compiler refused to pass the recognition result object across the hop to the main actor — a real data race that had been there all along. The callback now reads the Sendable facts (the transcript String, the isFinal Bool) and sends only those.
Why No Test Saw It, and Why It Only Happens Here
No test started the audio engine, so on the Simulator and on the cabled iPad alike the tap was never invoked and the check was never reached. The usual door to a runtime trap is an imported Objective-C or C block type, because in checked Swift the compiler refuses to send a non-Sendable closure across a boundary. The same runtime checks also guard calls arriving from code the compiler could not check, such as a witness reached through a @preconcurrency conformance. The rule the family took away: every closure a package hands to a C or Objective-C API is a review item, and the regression test calls the block from a detached task, asserting it carries no isolation.