To resume a Swift continuation exactly once, make completion and cancellation compete through one synchronized state machine. Store cancellation even before installation, select one terminal result, and release the lock before resuming the waiting task.

This tutorial builds a local callback-to-async adapter for a Mac command-line program. Its synthetic calculation doubles an integer; the example uses no network calls, records, or persistence. The central contract is explicit: whichever terminal event first changes the protected state wins.

What must the adapter guarantee?

A checked continuation bridges synchronous callbacks into asynchronous Swift code. Apple requires every execution path to resume it exactly once. Missing resumption leaves the task suspended; runtime checking helps identify misuse but does not implement coordination for you. CheckedContinuation

Cancellation introduces another potential owner of that resumption. Swift cancellation is cooperative: canceling a task does not automatically stop an arbitrary callback operation or cause a continuation to throw. The adapter must explicitly turn cancellation into its chosen result. Task.cancel()

For this example, the value contract is:

  • Inputs from -1000 through 1000 produce twice their value.
  • Other inputs produce CalculationError.outOfRange.
  • Cancellation produces CancellationError if it wins the state transition.
  • Once a terminal result is selected, later completion or cancellation events cannot replace it.
  • Each invocation owns a fresh gate and installs one continuation.

This means completion can win even when cancellation occurs before the caller receives the value. The deciding moment is the protected state transition, rather than the time the caller next executes. This is an application policy chosen for the example; it is not a universal ordering guarantee supplied by Swift.

Why can cancellation arrive before installation?

Apple documents that the cancellation handler runs before the operation closure when the task is already canceled. The operation still executes. Cancellation can also run concurrently with an operation already in progress. Consequently, a handler that merely resumes an optional continuation loses cancellation when that optional has not been populated yet. withTaskCancellationHandler(operation:onCancel:isolation:)

The gate therefore needs more information than whether a continuation currently exists. An absent continuation might mean installation has not happened, or that a terminal result has already been selected. These cases require different behavior.

Protected state Install the continuation Receive completion or cancellation
Empty Store the continuation Store the terminal result
Waiting Reject a second installation Remove the continuation and store the terminal result
Finished Resume the newly installed continuation with the stored result Ignore the later terminal event

The table is also a diagnostic aid. If an implementation cannot represent a terminal result before installation, inspect its already-canceled path. If two terminal events can both extract the continuation, inspect whether checking and removal happen in one critical section.

Build a local example on your Mac

Use a Swift 6 toolchain with the macOS SDK. Check the selected toolchain in Terminal with swift --version. The program below uses Foundation and Dispatch only; it needs no package dependencies. Swift’s command-line guide documents checking the installation and using an asynchronous @main entry point. Build a Command-line Tool

Save the following three Swift blocks, in order, into one file named ContinuationRace.swift. Together they form one complete program. The first block implements the gate, the second supplies the calculation and adapter, and the third supplies executable checks. Swift is unavailable in the research environment, so this program was inspected but was not compiled or executed here.

Implement the synchronized gate

Foundation documents NSLock as a way to protect a critical section. Locking and unlocking must happen on the same thread, and the lock is not recursive. Here, every critical section stays inside a synchronous method, contains no suspension point, and ends before continuation resumption. NSLock

import Foundation
import Dispatch

final class IntContinuationGate: @unchecked Sendable {
    private enum State {
        case empty
        case waiting(CheckedContinuation<Int, any Error>)
        case finished(Result<Int, any Error>)
    }

    private let lock = NSLock()
    private var state: State = .empty
    private var didInstall = false

    // Returns false if a terminal event already won.
    func install(
        _ continuation: CheckedContinuation<Int, any Error>
    ) -> Bool {
        lock.lock()
        precondition(!didInstall, "Install exactly once per gate")
        didInstall = true

        switch state {
        case .empty:
            state = .waiting(continuation)
            lock.unlock()
            return true

        case .waiting:
            preconditionFailure("Unexpected second installation")

        case .finished(let result):
            lock.unlock()
            continuation.resume(with: result)
            return false
        }
    }

    func finish(_ result: Result<Int, any Error>) {
        let continuation: CheckedContinuation<Int, any Error>?

        lock.lock()
        switch state {
        case .empty:
            continuation = nil
            state = .finished(result)

        case .waiting(let waiting):
            continuation = waiting
            state = .finished(result)

        case .finished:
            lock.unlock()
            return
        }
        lock.unlock()

        continuation?.resume(with: result)
    }

    func cancel() {
        finish(.failure(CancellationError()))
    }
}

The gate uses @unchecked Sendable because its mutable storage is protected manually. That annotation transfers responsibility to the implementation; it does not add synchronization. Both mutable properties remain private, and every access occurs under the same lock. Apple explicitly describes lock-protected state as a way to satisfy the semantic requirements of unchecked sendability. Sendable

Notice that finish changes the state before unlocking. A competing terminal event can then observe only finished, even if the winning thread has not yet called resume. The winner holds the extracted continuation locally; no competing call can extract it again.

Connect completion and cancellation to the same gate

The callback calculation uses a global dispatch queue. Apple documents that asynchronously submitted work proceeds separately from the submitting code, and that background dispatch work has no guaranteed thread identity. That makes thread-independent coordination appropriate here. DispatchQueue

enum CalculationError: Error {
    case outOfRange
}

func startDoubling(
    _ value: Int,
    completion: @escaping @Sendable (Result<Int, any Error>) -> Void
) {
    DispatchQueue.global().async {
        guard (-1000...1000).contains(value) else {
            completion(.failure(CalculationError.outOfRange))
            return
        }
        completion(.success(value * 2))
    }
}

func doubled(_ value: Int) async throws -> Int {
    let gate = IntContinuationGate()

    return try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation {
            (continuation: CheckedContinuation<Int, any Error>) in
            guard gate.install(continuation) else { return }

            startDoubling(value) { result in
                gate.finish(result)
            }
        }
    } onCancel: {
        gate.cancel()
    }
}

Create the gate before entering the cancellation-handler scope. An already-canceled task can then record cancellation immediately. When the continuation is installed afterward, installation resumes it with that stored failure and returns false, so the adapter skips starting the calculation.

Cancellation can still occur after installation returns true and before startDoubling is called. In that ordering, the calculation starts and finishes, but its result is ignored because cancellation already won. The wrapper cancels the wait; it does not promise to stop this tiny, bounded calculation.

The supported continuation bridge is withCheckedThrowingContinuation; its closure receives the continuation used to return a value or throw an error. The example spells out the continuation’s integer result and error types to keep inference unambiguous. withCheckedThrowingContinuation(isolation:function:_:)

Check event ordering without relying on sleeps

A timing delay does not establish which event won a race. The following checks instead drive all six orderings of installation, completion, and cancellation directly through the gate. Each sequence also receives extra terminal events to check that the selected outcome remains unchanged.

enum GateEvent {
    case install
    case complete
    case cancel
}

func checkOrder(
    _ events: [GateEvent],
    expected: Int?
) async throws {
    let gate = IntContinuationGate()

    do {
        let value: Int = try await withCheckedThrowingContinuation {
            (continuation: CheckedContinuation<Int, any Error>) in
            for event in events {
                switch event {
                case .install:
                    _ = gate.install(continuation)
                case .complete:
                    gate.finish(.success(42))
                case .cancel:
                    gate.cancel()
                }
            }

            // Neither of these may replace the selected result.
            gate.finish(.success(99))
            gate.cancel()
        }

        guard let expected else {
            preconditionFailure("Expected cancellation")
        }
        precondition(value == expected, "Unexpected value")
    } catch is CancellationError {
        precondition(expected == nil, "Unexpected cancellation")
    }
}

@main
struct ContinuationRaceDemo {
    static func main() async throws {
        try await checkOrder(
            [.install, .complete, .cancel], expected: 42
        )
        try await checkOrder(
            [.install, .cancel, .complete], expected: nil
        )
        try await checkOrder(
            [.complete, .install, .cancel], expected: 42
        )
        try await checkOrder(
            [.complete, .cancel, .install], expected: 42
        )
        try await checkOrder(
            [.cancel, .install, .complete], expected: nil
        )
        try await checkOrder(
            [.cancel, .complete, .install], expected: nil
        )

        let value = try await doubled(21)
        precondition(value == 42)

        do {
            _ = try await doubled(1001)
            preconditionFailure("Expected outOfRange")
        } catch CalculationError.outOfRange {
            // Expected input-domain error.
        }

        // Integration smoke check: either terminal outcome is valid.
        for _ in 0..<100 {
            let task = Task { try await doubled(21) }
            task.cancel()
            do {
                let result = try await task.value
                precondition(result == 42)
            } catch is CancellationError {
                // Cancellation won.
            }
        }

        print("Event-order checks and integration checks passed.")
    }
}

Compile and run from the directory containing the file:

swiftc -swift-version 6 -parse-as-library ContinuationRace.swift -o ContinuationRace
./ContinuationRace

The intended successful output is Event-order checks and integration checks passed. The explicit order checks validate the state transitions. The integration loop exercises the real adapter, but it does not guarantee that a particular scheduling interleaving occurs or prove that every concurrent execution has been tested.

Why does the implementation resume exactly once?

For at-most-once resumption, consider the first successful call to finish. It atomically replaces the current state with finished. If a continuation is waiting, that call alone extracts it. Every later finish call returns without resuming anything. If no continuation exists yet, the result waits for the single installation.

For eventual resumption, there are two paths. Installation happens first, and the first terminal event resumes the waiting continuation; or a terminal event happens first, and installation resumes with the stored result. This argument assumes installation happens once and at least one terminal event occurs. A general callback source that never completes, paired with a task that is never canceled, would still leave the caller waiting.

The example’s callback performs a bounded calculation and calls completion on both its success and input-error paths. Duplicate terminal callbacks are intentionally ignored after the winner. This is suitable for the specified single-result contract; a stream of meaningful values would need a different interface.

Which shortcuts break the contract?

Checking an optional continuation and then setting it to nil is insufficient when those actions are unsynchronized. Two concurrent callers can both read the same non-nil continuation before either removes it. The check, extraction, and terminal-state change must be one protected operation.

A cancellation check before creating the continuation also leaves a gap: cancellation can arrive immediately after that check. The handler and remembered terminal state close that gap. Swift’s language guide describes cancellation as cooperative and identifies cancellation handlers as the mechanism for immediate notification. Concurrency — The Swift Programming Language

Resuming while holding the shared lock introduces another problem. Apple specifically warns that cancellation handlers can run while internal task locks are held, and that continuation resumption may acquire those locks. The example therefore unlocks before every resume call. Cancellation handlers and locks

Finally, adding Task.checkCancellation() after the continuation returns would change this example’s outward contract: cancellation could replace a value even after completion won the gate. Decide that policy deliberately. For the contract implemented here, installation, completion, and cancellation all use one state machine, and its first terminal transition determines the caller’s result.

Sources