What Swift actors don't do
4 min read
Sonari transcribes speech locally, on your Mac, and it has two ways to start a transcription. One is live dictation: press the shortcut, talk, get text. The other lives in History, where you can take something you dictated last week and run it again through a sharper model. Both paths call the same method.
For a while, both of those paths shared a race. The cause was something I thought I understood about Swift actors, and didn’t.
Here’s the setup. The actual speech work happens in WhisperKit and FluidAudio, which load the model as CoreML and run it on the Neural Engine. Both do the genuinely hard part, and honestly they’re a pleasure to build on. I wrap each one in an engine type so the rest of the app doesn’t care which is loaded. The WhisperKit wrapper is an actor, because the WhisperKit object it holds keeps mutable state between calls, things like currentTimings, and is not safe to drive from two calls at once.
actor WhisperKitEngine: TranscriptionEngine { private var pipe: WhisperKit?
func transcribe(_ samples: [Float]) async throws -> String { guard let pipe else { throw SonariError.modelNotLoaded } return try await pipe.transcribe(audioArray: samples, decodeOptions: options) }}An actor serializes access to its state. Two callers can’t be inside it mutating pipe at the same time. So I put the transcriber behind one and thought I was done. If two transcriptions ever overlapped, the actor would sort it out.
It doesn’t.
That’s the part I had wrong. An actor serializes synchronous access. It does not hold the gate across a suspension. The moment a call hits an await, the actor is free to let the next one in while the first is parked. My whole method is one big await on pipe.transcribe. So two callers both walk up to the actor, both suspend on that line, and both drive the one WhisperKit instance at the same time. They race the mutable state I put the actor there to protect.
Worse, it only shows up when the two starts land close together, which is exactly the case that’s annoying to reproduce and easy to hit in real use: finish a dictation, tap re-transcribe on an old one a beat later, and they’re now interleaved.
The @preconcurrency import WhisperKit I’d written to quiet the compiler was, it turns out, a true warning I had silenced.
The compiler was right, and I had told it to be quiet.
The fix is a chain. Instead of leaning on the actor to order things, every request links itself behind whatever is already running, and only then touches the engine. It lives one level up, at the single point both paths route through:
func transcribe(_ samples: [Float]) async throws -> String { guard let engine else { throw SonariError.modelNotLoaded }
let previous = inFlight let task = Task { () throws -> String in _ = try? await previous?.value // wait out the prior call return try await engine.transcribe(samples) } inFlight = task // installed before any await
defer { if inFlight == task { inFlight = nil } } return try await task.value}The trick is one line, and it’s where that line sits. inFlight = task runs before the first await, and there is nothing suspendable between reading previous and installing task. That gap is the only window where the main actor could slip another transcribe in and break the ordering. Because there’s no await inside it, the window doesn’t exist. Each new task waits out the previous one before it calls the engine, so the engine calls are strictly ordered and never overlap, while every caller still gets its own result back.
I made it a queue on purpose. A request that arrives mid-transcription waits its turn and then runs. Nothing gets dropped, because both paths that reach this code have a person on the other end waiting for that specific result.
The same shape showed up in a second place once I knew to look. Loading a model is also async, and a slow download of one model can finish after you’ve already switched to another. So loads carry a generation number, and any load whose generation has been superseded quietly throws its result away instead of installing a stale engine. It’s the same lesson: an await is a door, and anything you care about the ordering of has to be closed against it deliberately.
None of this is exotic, and that’s what makes it worth writing down. “Put it behind an actor” is the advice you’ll get, and it’s good advice, right up until the thing you’re protecting is driven across a suspension by two callers. The actor was never going to save me there. A four-line chain did.
If a private, local dictation app is something you’d use, the list is at sonari.audio.
TJ