C.W.K.
Stream
Lesson 05 of 05 · published

The Ghost That Cancelled the Living

~12 min · race-condition, generation-token, state-machine, async

Level 0Unlit
0 XP0/36 lessons0/12 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
}

External links

Exercise

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.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.