Skip to content
C.W.K.
Stream
Lesson 07 of 07 · published

The Tap That Trapped

~12 min · swift-6, concurrency, actor-isolation, audio-thread, race-condition

Level 0Unlit
0 XP0/48 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"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.

Know which thread calls your closure, and say so in the type. In Swift 6, where a closure is written decides what isolation it is assumed to have. Any callback that a framework invokes on its own queue — audio taps, file monitors, network handlers — must be built outside actor context or marked @Sendable, or the language will check an assumption you never meant to make.
A stale task must not be able to write. Guarding cancellation alone is half the job. Any code that awaits and then writes shared state should first prove it still owns that state; otherwise a slow old task rewrites the new one's world.

Code

The two fixes (AudioCaptureService and FirekeeperKit DictationSessions)·swift
// 1. The tap block comes from the kit, built where no actor can be inferred.
// "Never an inline closure here. This method is MainActor, so Swift 6 would
//  infer the block MainActor-isolated and trap on AVFAudio's realtime queue."
input.installTap(onBus: 0, bufferSize: 4096, format: tapFormat,
                 block: AudioTap.block(into: session))

// 2. Every post-await write is owned by a session.
public struct DictationSessions: Sendable {
  public private(set) var current: UInt64 = 0
  @discardableResult
  public mutating func begin() -> UInt64 { current &+= 1; return current }
  public func owns(_ session: UInt64) -> Bool { session == current }
}

private func state(owning session: UInt64) -> AppState? {
  sessions.owns(session) ? state : nil   // a superseded pipeline writes nowhere
}

External links

Exercise

Search your code for closures handed to a framework that calls them on its own queue (audio, file events, sockets, timers). For each, write which thread will call it and what isolation the closure is written in. Where those differ, decide how you would build the closure so the type says the truth.
Hint
The safe patterns are: build the callback in a nonisolated function, mark it @Sendable and keep its captures sendable, or hop to the actor explicitly — but only inside a callback that is itself nonisolated or @Sendable. A hop inside a closure that already inherited the actor's isolation comes too late: the runtime check runs on entry, before the hop. The unsafe pattern is the one that compiles quietly: an inline closure inside an actor-isolated method, invoked from elsewhere.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.