"The compiler did not miss a bug. It inserted a check, and the check fired on a thread the code had never promised to stay off."
A Crash on the First Word
On 2026-09-15 Firekeeper started quitting right after launch, on the first recording. Both crash reports showed EXC_BREAKPOINT on RealtimeMessenger.mServiceQueue — AVFAudio's realtime queue, the thread that delivers microphone buffers. The faulting stack ran through swift_task_isCurrentExecutor: Swift itself had stopped the program.
What the Upgrade Changed
The day before, a family-wide sweep had moved Firekeeper to Swift 6 and the Observation framework, and added @MainActor to AudioCaptureService. That looked harmless; capture is started from the UI. But the microphone tap was an inline closure written inside a main-actor method. Under Swift 6, a closure that is not marked @Sendable inherits the isolation of the context it is written in, so the compiler treated the tap as main-actor code and emitted a runtime check that it was running on the main actor. AVFAudio's tap block type is not marked sendable, so nothing stopped the inference. The first buffer arrived on the realtime queue, the check failed, the app trapped. It surfaced a day late only because the installed app had not been rebuilt in between.
@MainActor final class AudioCaptureService {
func start() {
input.installTap(...) { buffer, _ in // inline closure, written in a MainActor method
... // -> inferred MainActor -> runtime executor check
} // AVFAudio calls it on its realtime queue -> TRAP
}
}
The Fix Already Existed
The family health app had hit exactly this crash five days earlier, and the shared kit already shipped the answer: CwkKitSpeech.AudioTap.block(into:) builds the tap block outside any actor, so no isolation can be inferred onto it. The sweep had simply not used it. Firekeeper now takes its tap from the kit and never writes one inline, and the level callback the tap feeds onward is typed @Sendable, because it would have been the next trap. Two Swift 6 warnings left over were closed later "with checked constructs, not silencing": a warning suppressed is a trap postponed.
Generations Must Guard Writes, Not Only Cancels
The double-tap lesson gave audio sessions generation tokens so a stale cancel could only no-op. A cross-brain review on 2026-09-27 found the same ghost one layer up. Cancel a dictation while its transcription is out, start a new recording, then let the old transcription come back: the old pipeline's cancellation handler set the shared phase to idle under the new recording. The capture's own generation guard correctly refused to stop the new microphone — so the mic stayed on, the phase said idle, and Stop was ignored. The fix extends generations from cancels to every write: a DictationSessions ledger begins a session on each start or cancel, a pipeline carries the session that was current when its recording stopped, and every write after an await goes through state(owning: session), which returns nothing for a superseded pipeline. Its late writes land nowhere.
@Sendable, or the language will check an assumption you never meant to make.