~12 min · race-condition, generation-token, state-machine, async
Level 0Unlit
0 XP0/48 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"'No recording was in progress' — said the app, while a recording was in progress."
The Feature and the Crash
The tap grammar is meant to be effortless: hold to push-to-talk; quick tap then a second tap to toggle hands-free. The first implementation was obvious and wrong: a quick tap cancelled the session, and a second tap started a new hands-free one. Sometimes it worked. Sometimes it died with "No recording was in progress" — an error message that was, at the moment it printed, factually false.
The Race
The first tap fires an async cancel. The second tap starts a fresh session. If the cancel is slow — and it sometimes is, because tearing down audio isn't instant — it arrives after the new session exists, doesn't know it's stale, and tears down the wrong recording. A ghost from a session that's already dead reaches out and kills the living one. Then the code that expected a session finds nothing, and reports the absurdity.
tap 1 -> cancel session A (async, slow...)
tap 2 -> start session B (fast)
... cancel-A finally lands ...
-> tears down B # the ghost killed the wrong session
-> "No recording was in progress"
Two Fixes, Both Necessary
The first fix removes the race entirely: never restart the session. A quick tap no longer cancels — it leaves the session recording through a short decision window (about 0.45s). If a second tap arrives, the same session converts to hands-free in place; if the window expires with no second tap, then it cancels. There is no teardown-then-startup gap for a ghost to land in. The second fix is defense in depth: stamp every audio session with a generation token, and make any cancel check its generation before acting. A cancel from generation 1 arriving at generation 2 is simply a no-op. Fix one removes the race; fix two guarantees that if a race ever reappears, it can't do damage.
Convert in place instead of tearing down and rebuilding. Whenever a state change can be expressed as mutating the existing thing rather than destroying it and creating a replacement, prefer that — the destroy-create gap is where every stale-async bug lives. No gap, no ghost.
Stamp async work with a generation, and check it on arrival. Any cancel, callback, or completion that can outlive its originator needs to prove it's still relevant before it acts. A monotonically increasing generation token on the resource, compared at the moment of effect, turns 'stale work corrupts current state' into 'stale work harmlessly no-ops'. It's a few lines and it kills an entire bug class.
Code
Convert in place; stale generations can only no-op·swift
actor AudioSession {
private var generation = 0
func start() -> Int {
generation += 1
return generation // caller holds its generation token
}
func cancel(generation g: Int) {
guard g == generation else { return } // stale ghost -> no-op
teardown()
}
}
// Quick tap does NOT cancel. It opens a decision window on the SAME session.
func onQuickTap() {
phase = .recording(pendingDecision: true)
decisionTask = Task {
try? await Task.sleep(for: .milliseconds(450))
guard !Task.isCancelled else { return }
await stopAndCancel() // window expired, no second tap
}
}
func onSecondTap() {
decisionTask?.cancel()
phase = .recording(handsFree: true) // convert IN PLACE — never restart
}
Find async code where a cancel, timeout, or callback could arrive after its target is gone and a replacement exists. Trace the ghost: what does the late arrival do to the new object? Then add a generation token and describe how the same sequence now plays out harmlessly.
Hint
The ghost pattern is always: slow teardown of A lands after fast creation of B, and the teardown has no way to know B isn't A. With a generation token, the late cancel compares its stamp to the current one, sees a mismatch, and returns without touching anything. The sequence still happens — it just stops mattering.
Progress
Progress is local-only — sign in to sync across devices.