Test Swift Callback-to-Async Adapters on a Cloud Mac

Test Swift Callback-to-Async Adapters on a Cloud Mac

Test Swift Callback-to-Async Adapters on a Cloud Mac

An older SDK’s completion callback works reliably in local tests. After moving to parallel execution on a cloud Mac, however, a continuation occasionally gets resumed twice, or a canceled task never receives a result. The problem is usually not machine performance. It is the adapter’s assumption that the callback fires exactly once and always arrives asynchronously. Below, we remove both assumptions and check the boundaries with a standalone Swift program, without a real network or arbitrary delays.

Define who gets to decide the final result

This article covers one-shot operations: of success, failure, and cancellation, whichever result is committed to the state machine first wins. Later notifications cannot change that result or resume the waiting caller again. This contract does not apply to streaming APIs or interfaces that deliver multiple updates.

Two pieces of state must be retained: the continuation, which may not yet be installed, and the final result, which may already be decided. Cancellation can happen before the continuation is installed, so a single optional variable is not enough to determine whether the task has finished.

A checked continuation detects misuse, but it does not deduplicate notifications for you or automatically turn task cancellation into cancellation of the underlying operation.

The rule here is “first commit wins,” not “cancellation always takes priority.” Once success has been committed, later cancellation cannot override it, even if the resumption has not yet been scheduled to run. If your application requires stricter cancellation priority, define a different commit point rather than adding ad hoc checks around the returned value.

Protect completion state with a short critical section

Save the following code as BridgeCheck.swift. The example uses Swift 6 and a macOS SDK that provides NSLock.withLock. The lock protects only state reads and writes. External callbacks, request startup, and continuation resumption all happen outside the lock.

import Foundation

final class CompletionGate<Value: Sendable>: @unchecked Sendable {
    private let lock = NSLock()
    private var outcome: Result<Value, any Error>?
    private var waiter: CheckedContinuation<Value, any Error>?

    func install(_ continuation: CheckedContinuation<Value, any Error>) {
        let ready: Result<Value, any Error>? = lock.withLock {
            if let outcome {
                return outcome
            }
            precondition(waiter == nil)
            waiter = continuation
            return nil
        }
        if let ready {
            continuation.resume(with: ready)
        }
    }

    func finish(_ result: Result<Value, any Error>) {
        let continuation: CheckedContinuation<Value, any Error>? =
            lock.withLock {
                guard outcome == nil else {
                    return nil
                }
                outcome = result
                let current = waiter
                waiter = nil
                return current
            }
        continuation?.resume(with: result)
    }
}

func bridge(
    start: @Sendable (
        @escaping @Sendable (Result<Int, any Error>) -> Void
    ) -> Void
) async throws -> Int {
    let gate = CompletionGate<Int>()
    return try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation {
            (continuation: CheckedContinuation<Int, any Error>) in
            gate.install(continuation)
            start { result in
                gate.finish(result)
            }
        }
    } onCancel: {
        gate.finish(.failure(CancellationError()))
    }
}

@unchecked Sendable does not mean disabling checks and forgetting about them. It is a promise from the maintainer: every shared mutable field is protected by the same lock, and the result value satisfies Sendable. Any counters or handles added later must also have their synchronization reviewed.

Installation and completion can occur in only two orders. If installation happens first, completion takes the waiting continuation and resumes it. If completion happens first, it stores the result and resumes the continuation when it is installed. Clearing the waiter releases the reference promptly, but the result state remains the sole indication that the operation has finished. A cleared waiter must not be mistaken for a state that accepts another result.

Cover critical orderings with deterministic tests

Append the following entry point to the same file. The first case deliberately calls back twice synchronously. The second starts an operation that never calls back, then cancels the waiting task. Neither case uses sleeps to guess when something will be scheduled.

@main
struct BridgeCheck {
    static func main() async throws {
        let value = try await bridge { complete in
            complete(.success(7))
            complete(.success(9))
        }
        precondition(value == 7)

        let pending = Task {
            try await bridge { _ in }
        }
        pending.cancel()

        do {
            _ = try await pending.value
            preconditionFailure("Expected cancellation")
        } catch is CancellationError {
        }
    }
}
xcrun swiftc -parse-as-library -swift-version 6 \
  BridgeCheck.swift -o BridgeCheck
./BridgeCheck

A normal run should finish with exit code zero. The synchronous completion case catches incorrect implementations that start the operation before storing the continuation. Duplicate notifications catch an unguarded second resumption. The cancellation case checks whether the waiting caller can finish when no callback arrives.

Once this is integrated into the project’s tests, also test the state machine directly with completion before installation, and run success and failure in both orders. Concurrent stress tests can supplement this coverage, but they cannot replace cases with explicitly controlled ordering.

Canceling the wait does not stop the underlying operation

This adapter guarantees that the waiting caller finishes, but it does not stop the underlying work. If cancellation arrives first, start may still run, and subsequent notifications will be ignored. This is an intentional boundary; the example should not be treated as a complete network client.

Extend the adapter when a cancellation handle is available

If the real API returns a cancellation handle, register it in the same protected state. If cancellation arrives first, record a flag; when the handle is returned later, immediately take it and cancel the operation. If the handle arrives first, the cancellation path takes it instead. The actual cancellation call must still happen outside the lock because it may synchronously trigger the completion callback.

Do not check cancellation just once before starting. There is still a race between that check and the handle being returned. Do not call the cancellation API while holding the lock either: a synchronous callback that reenters the state machine could deadlock.

If the underlying operation never sends a notification and the caller never cancels, the wait continues. Application-level timeouts must use the same completion entry point and also handle cleanup of the underlying resources. Do not assume that a continuation has a built-in timeout.

Add acceptance checks to the cloud execution entry point

Compile and run this check in the pre-build stage on the cloud Mac, recording the compiler version, commit identifier, and exit code. The example does not require a graphical session and is suitable for repeated command-line execution.

Before merging, verify four things: each call has its own state machine; synchronous callbacks are not lost; duplicate notifications do not cause duplicate resumptions; and cancellation leaves no waiting caller suspended. For a real API with a handle, add a dedicated test in which cancellation happens before the handle is returned.

Finally, review closure retention. Even after the wait has ended, the underlying operation may still retain the completion closure; deduplication in the state machine does not solve every lifetime issue. The async wrapper is not fully accounted for until the completion contract, the stopping contract, and resource cleanup have each been validated separately.

Frequently asked questions

Does a checked continuation ignore duplicate callbacks?

No. Resuming it twice violates its contract and can trigger a runtime failure. Select one terminal result under synchronization, then resume outside the lock.

Does cancelling the task stop the underlying request?

Not automatically. This adapter ends the wait with a cancellation error. The underlying operation needs its own cancellation API and handling for cancellation before its handle becomes available.

Hand off your next build to a dedicated Cloud Mac

MacFTP provides dedicated Apple Silicon physical machines running native macOS, with daily, weekly, monthly, and quarterly rental options. Check the configuration, then connect your existing toolchain to a node. Availability is confirmed in real time in the console.

Rent a Cloud Mac

From reading to building

Check the configuration, then plan your deployment

Compare models and rental terms, or return to the blog for more technical guides.