What happens when a continuation is resumed twice or never, and how do you wrap a callback API correctly?
Predict first
A callback that calls back twice is wrapped in a checked continuation. What happens?
import Foundation
func legacy(_ done: @escaping (Int) -> Void) { done(1); done(2) }
let v = await withCheckedContinuation { (c: CheckedContinuation<Int, Never>) in legacy { c.resume(returning: $0) } }
print("unreachable", v)
- It traps
- It prints "unreachable 1"
- It prints "unreachable 2"
Mental model. A continuation is a single-use return ticket: using it twice is refused at the gate, and losing it means the passenger waits at the station forever.
A continuation turns a callback into a suspension point, and the contract is resume exactly once. With withCheckedContinuation the runtime enforces it. Resuming twice is a fatal error: the check wraps a callback that fires twice and dies with “SWIFT TASK CONTINUATION MISUSE: … tried to resume its continuation more than once” (the process traps). Never resuming is the quieter bug: the awaiting task hangs forever, and the checked version logs “leaked its continuation without resuming it” when the continuation is released, which is your hint during development. The unchecked variant (withUnsafeContinuation) does no such checking, so a mistake becomes silent undefined behaviour or a hang; use checked everywhere except a measured hot path.
Wrap carefully. Resume on every path of the callback, including errors, timeouts and the “cancelled” case; if the API can call back more than once (a delegate, progress updates, a notification), a continuation is the wrong tool: use an AsyncStream (Q300). Guard against double calls from a flaky API with a flag protected by a lock, or restructure so the callback is stored and cleared. Respect cancellation with withTaskCancellationHandler so the underlying request is cancelled and the continuation resumed with CancellationError. Keep the wrapper tiny, with the continuation captured only by the callback.
The checked continuation detects the second resume and terminates the process.
Common traps
- Using a continuation for an API that calls back repeatedly. The second call is a crash.
- Forgetting the error or early-return path. The task hangs and nothing says why.
They might follow up with
- Does the check catch a leak immediately? No: the diagnostic appears when the continuation is destroyed, so a stored one that stays alive hides the bug.
- Where do I resume from? Anywhere; the continuation is resumed on the caller’s behalf and the awaiting task continues on its own executor.
Related questions
- 10.12.1 What does an AsyncStream do when the producer yields faster than the consumer reads, and how do the buffering…
- 10.12.3 How do you consume NotificationCenter, delegates and other repeated callbacks as async sequences, and what…
- 10.8.4 How does cancellation actually reach your code: what do checkCancellation, isCancelled…
- 10.6.1 How do continuations bridge callback APIs, and what is the difference between checked and unsafe?
- 10.6.2 How do you bridge repeated events, GCD and Combine into async code with AsyncStream?
← What does an AsyncStream do when the producer yields faster than the… How do you consume NotificationCenter, delegates and other repeated… →