"The user pressed Stop during the permission prompt. Then they answered the prompt — and the microphone opened."
Suspension Is Not a Pause Button on the World
An await is a suspension point. While your function waits, other code runs — including other calls into the same actor. When you resume, the state you checked before the await may no longer be true. This is actor reentrancy, and it is by design: an actor that refused all work while one call waited on the network would deadlock itself.
The practical rule: after every await, re-check whatever your next line assumes. It matters most when the thing you await is answered by a person — a permission dialog, a file picker, a share sheet, an alert — because a person can take minutes, and the object that started the wait can be stopped, torn down and rebuilt in the meantime.
The Stop That Did Not Stop
The family's dictation turn awaited two system permission prompts inside start(). Its Stop button was already live during that phase, and stop() dutifully set the state to idle and tore everything down. Then the user answered the dialog, the continuation resumed, and start() simply carried on: audio session, tap, engine. A turn the user had stopped opened the microphone, and the state said "listening". It was reachable on the very first dictation of a fresh install — exactly when someone hesitates.
The fix needed no new state. The turn already had a generation counter for rejecting late callbacks. start() captures its generation before the first await and checks it after each one; stop() advances it, so every earlier start becomes stale. A cancellation handler alone would not have been enough: the continuation resumes normally when the user finally answers.
The first regression test proved nothing. It asserted "not listening" after the stop, and it passed against the broken code — because in the Simulator the Korean speech model is missing, so start() bailed out a few lines later anyway. The test was measuring the Simulator. Retargeted to state == .idle, which only the guard produces, it failed against the old code and passed against the fix.
Continuations, Deadlines, and Assertions
- A checked continuation must resume exactly once. Resuming twice traps; never resuming leaks a suspended task forever.
- Some callbacks never come. A share extension waiting on a PDF from Mail sat alive for an hour because the load's completion was never called on the device. Every such wait needs a deadline. Not a task group — a group waits for every child, and the whole point is a child that never returns — but a first-wins race: two tasks, one locked gate, one continuation.
XCTAssertEqual(await store.state(id), .queued)does not compile. XCTest assertion arguments are autoclosures that cannot await. Hoist the value into aletfirst, then assert on it.