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

Every await Is a Door Someone Can Close

~17 min · concurrency, reentrancy, cancellation, continuations, deadlines

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"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 a let first, then assert on it.

Code

Re-check after every await that a person answers·swift
@MainActor
public final class TurnController {
    private var generation = 0
    public private(set) var state = "idle"

    public func start(askPermission: () async -> Bool) async {
        generation += 1
        let mine = generation
        state = "starting"
        let granted = await askPermission()          // a person answers this; anything can happen meanwhile
        guard mine == generation else { return }     // stopped while we waited: do nothing
        state = granted ? "listening" : "denied"
    }

    public func stop() {
        generation += 1                              // every earlier start is now stale
        state = "idle"
    }
}
A first-wins deadline for work that may never finish·swift
import Foundation

public enum Deadline {
    public struct TimedOut: Error {}

    // Not a task group: a group waits for every child, including the one that never returns.
    public static func race<T: Sendable>(
        seconds: Double, _ work: @escaping @Sendable () async throws -> T
    ) async throws -> T {
        try await withCheckedThrowingContinuation { continuation in
            let gate = Gate()
            Task {
                do { let value = try await work(); if gate.claim() { continuation.resume(returning: value) } }
                catch { if gate.claim() { continuation.resume(throwing: error) } }
            }
            Task {
                try? await Task.sleep(for: .seconds(seconds))
                if gate.claim() { continuation.resume(throwing: TimedOut()) }
            }
        }
    }

    final class Gate: @unchecked Sendable {             // resume exactly once
        private let lock = NSLock()
        private var done = false
        func claim() -> Bool { lock.lock(); defer { lock.unlock() }; if done { return false }; done = true; return true }
    }
}

External links

Exercise

Add TurnController to Spark with an injectable askPermission closure. Write a test that starts a turn with a permission closure that waits on a continuation you control, calls stop() while it waits, then resumes the continuation with true and asserts the state is still idle. Delete the generation guard and confirm the test fails. Finally, use Deadline.race to wrap a load that never completes and assert it throws TimedOut within the deadline.
Hint
Control the permission answer with withCheckedContinuation stored in a variable the test resumes later. The assertion must be on something only the guard produces (state == "idle"), not on something the environment might also produce.

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.